Skip to main content

oxiphysics_materials/smart_materials/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions::*;
7use std::f64::consts::PI;
8
9/// Magnetorheological (MR) fluid modeled as a Bingham plastic.
10#[derive(Debug, Clone, Copy)]
11pub struct MagnetorheologicalFluid {
12    /// Off-state viscosity \[Pa·s\].
13    pub base_viscosity: f64,
14    /// Field-independent yield stress offset \[Pa\].
15    pub tau0_base: f64,
16    /// Field-dependent yield stress coefficient \[Pa·(m/A)^alpha\].
17    pub tau_h_coeff: f64,
18    /// Field exponent (typically ~1-2).
19    pub field_exponent: f64,
20    /// Particle volume fraction.
21    pub volume_fraction: f64,
22}
23impl MagnetorheologicalFluid {
24    /// Create an MR fluid model.
25    pub fn new(
26        base_viscosity: f64,
27        tau0_base: f64,
28        tau_h_coeff: f64,
29        field_exponent: f64,
30        volume_fraction: f64,
31    ) -> Self {
32        Self {
33            base_viscosity,
34            tau0_base,
35            tau_h_coeff,
36            field_exponent,
37            volume_fraction,
38        }
39    }
40    /// Yield stress at magnetic field H \[A/m\].
41    pub fn yield_stress(&self, h_field: f64) -> f64 {
42        self.tau0_base + self.tau_h_coeff * h_field.powf(self.field_exponent)
43    }
44    /// Shear stress for given shear rate γ̇ \[1/s\] and field H \[A/m\] (Bingham model).
45    pub fn shear_stress(&self, shear_rate: f64, h_field: f64) -> f64 {
46        let tau_y = self.yield_stress(h_field);
47        if shear_rate.abs() < 1e-12 {
48            return tau_y;
49        }
50        tau_y + self.base_viscosity * shear_rate
51    }
52    /// Mason number: ratio of viscous to magnetic forces.
53    /// Mn = η * γ̇ / (μ₀ * H²).
54    pub fn mason_number(&self, shear_rate: f64, h_field: f64) -> f64 {
55        const MU0: f64 = 4.0 * PI * 1e-7;
56        if h_field < 1e-10 {
57            return f64::INFINITY;
58        }
59        self.base_viscosity * shear_rate / (MU0 * h_field * h_field)
60    }
61    /// Apparent viscosity at given field and shear rate.
62    pub fn apparent_viscosity(&self, shear_rate: f64, h_field: f64) -> f64 {
63        if shear_rate.abs() < 1e-12 {
64            return f64::INFINITY;
65        }
66        self.shear_stress(shear_rate, h_field) / shear_rate
67    }
68}
69/// Shape memory alloy model using the Tanaka-Liang-Rogers cosine model.
70/// Tracks martensite fraction ξ ∈ \[0, 1\], where 1 = full martensite.
71#[derive(Debug, Clone)]
72pub struct ShapeMemoryAlloy {
73    /// Martensite start temperature \[K\].
74    pub ms: f64,
75    /// Martensite finish temperature \[K\].
76    pub mf: f64,
77    /// Austenite start temperature \[K\].
78    pub a_s: f64,
79    /// Austenite finish temperature \[K\].
80    pub af: f64,
81    /// Current martensite fraction ξ ∈ \[0, 1\].
82    pub xi: f64,
83    /// Austenite elastic modulus \[Pa\].
84    pub e_a: f64,
85    /// Martensite elastic modulus \[Pa\].
86    pub e_m: f64,
87    /// Maximum recoverable strain ε_L.
88    pub max_strain: f64,
89    /// Stress influence coefficient for martensite transformation \[Pa/K\].
90    pub cm: f64,
91    /// Stress influence coefficient for austenite transformation \[Pa/K\].
92    pub ca: f64,
93}
94impl ShapeMemoryAlloy {
95    /// Create an SMA model with transformation temperatures and elastic moduli.
96    #[allow(clippy::too_many_arguments)]
97    pub fn new(
98        ms: f64,
99        mf: f64,
100        a_s: f64,
101        af: f64,
102        e_a: f64,
103        e_m: f64,
104        max_strain: f64,
105        cm: f64,
106        ca: f64,
107    ) -> Self {
108        Self {
109            ms,
110            mf,
111            a_s,
112            af,
113            xi: 1.0,
114            e_a,
115            e_m,
116            max_strain,
117            cm,
118            ca,
119        }
120    }
121    /// Create a standard NiTi Nitinol model with typical parameters.
122    pub fn nitinol() -> Self {
123        Self::new(291.0, 273.0, 307.0, 325.0, 75e9, 28e9, 0.08, 8e6, 13e6)
124    }
125    /// Elastic modulus at current martensite fraction.
126    pub fn elastic_modulus(&self) -> f64 {
127        self.e_a + self.xi * (self.e_m - self.e_a)
128    }
129    /// Update martensite fraction for given temperature T \[K\] and stress σ \[Pa\].
130    /// Returns updated ξ.
131    pub fn update_phase(&mut self, temperature: f64, stress: f64) -> f64 {
132        let ms_stress = self.ms + stress / self.cm;
133        let mf_stress = self.mf + stress / self.cm;
134        if temperature <= ms_stress && temperature >= mf_stress {
135            let xi_m =
136                (1.0 + (PI * (temperature - ms_stress) / (mf_stress - ms_stress)).cos()) / 2.0;
137            self.xi = xi_m.clamp(self.xi, 1.0);
138        }
139        let as_stress = self.a_s + stress / self.ca;
140        let af_stress = self.af + stress / self.ca;
141        if temperature >= as_stress && temperature <= af_stress {
142            let xi_a = self.xi
143                * (1.0 + (PI * (temperature - as_stress) / (af_stress - as_stress)).cos())
144                / 2.0;
145            self.xi = xi_a.clamp(0.0, self.xi);
146        }
147        if temperature < mf_stress {
148            self.xi = 1.0;
149        }
150        if temperature > af_stress {
151            self.xi = 0.0;
152        }
153        self.xi
154    }
155    /// Current phase classification.
156    pub fn phase(&self) -> SmaPhase {
157        if self.xi > 0.99 {
158            SmaPhase::Martensite
159        } else if self.xi < 0.01 {
160            SmaPhase::Austenite
161        } else {
162            SmaPhase::Mixed
163        }
164    }
165    /// Recovery stress for given strain \[Pa\].
166    pub fn recovery_stress(&self, strain: f64) -> f64 {
167        self.elastic_modulus() * (strain - self.xi * self.max_strain)
168    }
169}
170/// Electrorheological (ER) fluid model based on dielectric polarization.
171#[derive(Debug, Clone, Copy)]
172pub struct ElectrorheologicalFluid {
173    /// Base (off-state) viscosity \[Pa·s\].
174    pub base_viscosity: f64,
175    /// Electric field coefficient for yield stress \[Pa·(m/V)²\].
176    pub field_coeff: f64,
177    /// Saturation field strength \[V/m\].
178    pub saturation_field: f64,
179    /// Dielectric constant of particles.
180    pub epsilon_p: f64,
181    /// Dielectric constant of carrier fluid.
182    pub epsilon_f: f64,
183}
184impl ElectrorheologicalFluid {
185    /// Create an ER fluid model.
186    pub fn new(
187        base_viscosity: f64,
188        field_coeff: f64,
189        saturation_field: f64,
190        epsilon_p: f64,
191        epsilon_f: f64,
192    ) -> Self {
193        Self {
194            base_viscosity,
195            field_coeff,
196            saturation_field,
197            epsilon_p,
198            epsilon_f,
199        }
200    }
201    /// Yield stress at electric field E \[V/m\].
202    pub fn yield_stress(&self, e_field: f64) -> f64 {
203        let e_eff = e_field.min(self.saturation_field);
204        self.field_coeff * e_eff * e_eff
205    }
206    /// Shear stress using Bingham model with electric field.
207    pub fn shear_stress(&self, shear_rate: f64, e_field: f64) -> f64 {
208        let tau_y = self.yield_stress(e_field);
209        if shear_rate.abs() < 1e-12 {
210            return tau_y;
211        }
212        tau_y + self.base_viscosity * shear_rate
213    }
214    /// Dielectric loss factor (simplified).
215    pub fn beta_parameter(&self) -> f64 {
216        (self.epsilon_p - self.epsilon_f) / (self.epsilon_p + 2.0 * self.epsilon_f)
217    }
218}
219/// Ionic polymer-metal composite (IPMC) actuator model.
220#[derive(Debug, Clone, Copy)]
221pub struct PolymerActuator {
222    /// Bending curvature constant \[1/(V·m)\].
223    pub curvature_gain: f64,
224    /// Time constant for bending response \[s\].
225    pub time_constant: f64,
226    /// Length of IPMC strip \[m\].
227    pub length: f64,
228    /// Thickness \[m\].
229    pub thickness: f64,
230    /// Blocking force per volt \[N/V\].
231    pub blocking_force_gain: f64,
232    /// Current bending state (time integral).
233    pub bending_state: f64,
234}
235impl PolymerActuator {
236    /// Create an IPMC actuator.
237    pub fn new(
238        curvature_gain: f64,
239        time_constant: f64,
240        length: f64,
241        thickness: f64,
242        blocking_force_gain: f64,
243    ) -> Self {
244        Self {
245            curvature_gain,
246            time_constant,
247            length,
248            thickness,
249            blocking_force_gain,
250            bending_state: 0.0,
251        }
252    }
253    /// Steady-state tip deflection for step voltage V \[m\].
254    pub fn steady_state_deflection(&self, voltage: f64) -> f64 {
255        let kappa = self.curvature_gain * voltage;
256        kappa * self.length * self.length / 2.0
257    }
258    /// Transient tip deflection at time t \[s\] after step voltage V.
259    pub fn transient_deflection(&self, voltage: f64, t: f64) -> f64 {
260        let delta_ss = self.steady_state_deflection(voltage);
261        delta_ss * (1.0 - (-t / self.time_constant).exp())
262    }
263    /// Blocking force at given voltage \[N\].
264    pub fn blocking_force(&self, voltage: f64) -> f64 {
265        self.blocking_force_gain * voltage
266    }
267    /// Curvature for given voltage \[1/m\].
268    pub fn curvature(&self, voltage: f64) -> f64 {
269        self.curvature_gain * voltage
270    }
271}
272/// Dielectric elastomer actuator using Maxwell stress.
273#[derive(Debug, Clone, Copy)]
274pub struct DielectricElastomer {
275    /// Relative permittivity of the elastomer.
276    pub epsilon_r: f64,
277    /// Undeformed thickness \[m\].
278    pub thickness: f64,
279    /// Initial area \[m²\].
280    pub initial_area: f64,
281    /// Elastic modulus of elastomer \[Pa\].
282    pub elastic_modulus: f64,
283    /// Maximum electric field (breakdown) \[V/m\].
284    pub breakdown_field: f64,
285}
286impl DielectricElastomer {
287    /// Create a dielectric elastomer actuator.
288    pub fn new(
289        epsilon_r: f64,
290        thickness: f64,
291        initial_area: f64,
292        elastic_modulus: f64,
293        breakdown_field: f64,
294    ) -> Self {
295        Self {
296            epsilon_r,
297            thickness,
298            initial_area,
299            elastic_modulus,
300            breakdown_field,
301        }
302    }
303    /// Maxwell stress (electrostatic pressure) at field E \[Pa\].
304    pub fn maxwell_stress(&self, e_field: f64) -> f64 {
305        const EPS0: f64 = 8.854e-12;
306        self.epsilon_r * EPS0 * e_field * e_field
307    }
308    /// Actuation strain (thickness reduction fraction) at voltage V \[V\].
309    pub fn actuation_strain(&self, voltage: f64) -> f64 {
310        let e_field = voltage / self.thickness;
311        let e_eff = e_field.min(self.breakdown_field);
312        let p = self.maxwell_stress(e_eff);
313        (p / self.elastic_modulus).min(0.9)
314    }
315    /// Area strain at voltage V (incompressible: λ_z²*λ_A = 1).
316    pub fn area_strain(&self, voltage: f64) -> f64 {
317        2.0 * self.actuation_strain(voltage)
318    }
319    /// Actuation pressure \[Pa\] at voltage V.
320    pub fn actuation_pressure(&self, voltage: f64) -> f64 {
321        let e_field = (voltage / self.thickness).min(self.breakdown_field);
322        self.maxwell_stress(e_field)
323    }
324    /// Electric field for given voltage \[V/m\].
325    pub fn electric_field(&self, voltage: f64) -> f64 {
326        voltage / self.thickness
327    }
328}
329/// Bang-bang + proportional controller for SMA wire actuator temperature.
330#[derive(Debug, Clone)]
331pub struct SmaController {
332    /// Reference SMA model.
333    pub sma: ShapeMemoryAlloy,
334    /// Target martensite fraction.
335    pub target_xi: f64,
336    /// Temperature setpoint for full austenite \[K\].
337    pub t_hot: f64,
338    /// Temperature setpoint for full martensite \[K\].
339    pub t_cold: f64,
340    /// Proportional gain for temperature error.
341    pub kp: f64,
342    /// Current temperature \[K\].
343    pub temperature: f64,
344    /// Maximum heating power \[W\].
345    pub max_power: f64,
346}
347impl SmaController {
348    /// Create an SMA controller.
349    pub fn new(sma: ShapeMemoryAlloy, t_hot: f64, t_cold: f64, kp: f64, max_power: f64) -> Self {
350        let temperature = t_cold;
351        Self {
352            sma,
353            target_xi: 0.0,
354            t_hot,
355            t_cold,
356            kp,
357            temperature,
358            max_power,
359        }
360    }
361    /// Set target martensite fraction.
362    pub fn set_target(&mut self, xi_target: f64) {
363        self.target_xi = xi_target.clamp(0.0, 1.0);
364    }
365    /// Compute heating power to achieve target (bang-bang + proportional).
366    pub fn compute_power(&self) -> f64 {
367        let xi_err = self.target_xi - self.sma.xi;
368        if xi_err > 0.1 {
369            0.0
370        } else if xi_err < -0.1 {
371            self.max_power
372        } else {
373            (-self.kp * xi_err * self.max_power).clamp(0.0, self.max_power)
374        }
375    }
376    /// Step the controller by dt \[s\] with thermal resistance R_th \[K/W\].
377    /// `t_ambient` is environment temperature \[K\].
378    pub fn step(&mut self, dt: f64, r_thermal: f64, t_ambient: f64) {
379        let power = self.compute_power();
380        let thermal_mass = 1e-4;
381        let q_loss = (self.temperature - t_ambient) / r_thermal;
382        let d_temp = (power - q_loss) / thermal_mass * dt;
383        self.temperature = (self.temperature + d_temp).clamp(self.t_cold, self.t_hot);
384        self.sma.update_phase(self.temperature, 0.0);
385    }
386}
387/// Distributed actuator and sensor layout for a smart structure.
388#[derive(Debug, Clone)]
389pub struct SmartStructure {
390    /// Number of structural modes.
391    pub n_modes: usize,
392    /// Modal frequencies \[Hz\].
393    pub modal_frequencies: Vec<f64>,
394    /// Modal damping ratios.
395    pub modal_damping: Vec<f64>,
396    /// Actuator positions (normalized 0..1 along structure).
397    pub actuator_positions: Vec<f64>,
398    /// Sensor positions.
399    pub sensor_positions: Vec<f64>,
400    /// Modal state: (displacement, velocity) per mode.
401    pub modal_state: Vec<[f64; 2]>,
402    /// Control gains per mode.
403    pub control_gains: Vec<f64>,
404}
405impl SmartStructure {
406    /// Create a smart structure model.
407    pub fn new(
408        n_modes: usize,
409        modal_frequencies: Vec<f64>,
410        modal_damping: Vec<f64>,
411        actuator_positions: Vec<f64>,
412        sensor_positions: Vec<f64>,
413    ) -> Self {
414        let control_gains = vec![1.0; n_modes];
415        let modal_state = vec![[0.0; 2]; n_modes];
416        Self {
417            n_modes,
418            modal_frequencies,
419            modal_damping,
420            actuator_positions,
421            sensor_positions,
422            modal_state,
423            control_gains,
424        }
425    }
426    /// Mode shape (assumed sinusoidal basis): φ_n(x) = sin(nπx).
427    fn mode_shape(&self, mode: usize, position: f64) -> f64 {
428        ((mode + 1) as f64 * PI * position).sin()
429    }
430    /// Compute modal control forces from sensor readings.
431    pub fn modal_control(&self, sensor_readings: &[f64]) -> Vec<f64> {
432        let mut u = vec![0.0; self.actuator_positions.len()];
433        for (k, &x_act) in self.actuator_positions.iter().enumerate() {
434            for m in 0..self.n_modes {
435                let obs: f64 = sensor_readings
436                    .iter()
437                    .zip(self.sensor_positions.iter())
438                    .map(|(&y, &x_s)| y * self.mode_shape(m, x_s))
439                    .sum();
440                let vel = self.modal_state[m][1];
441                let phi_act = self.mode_shape(m, x_act);
442                u[k] -= self.control_gains[m] * (obs + vel) * phi_act;
443            }
444        }
445        u
446    }
447    /// Integrate modal equations of motion under distributed forcing.
448    pub fn step(&mut self, forcing: &[f64], dt: f64) {
449        for m in 0..self.n_modes {
450            let omega = 2.0 * PI * self.modal_frequencies[m];
451            let zeta = self.modal_damping[m];
452            let q = self.modal_state[m][0];
453            let qdot = self.modal_state[m][1];
454            let f_gen: f64 = forcing
455                .iter()
456                .zip(self.actuator_positions.iter())
457                .map(|(&f, &x)| f * self.mode_shape(m, x))
458                .sum();
459            let qddot = f_gen - 2.0 * zeta * omega * qdot - omega * omega * q;
460            self.modal_state[m][0] = q + qdot * dt;
461            self.modal_state[m][1] = qdot + qddot * dt;
462        }
463    }
464    /// Physical displacement at position x from modal superposition.
465    pub fn displacement_at(&self, x: f64) -> f64 {
466        (0..self.n_modes)
467            .map(|m| self.modal_state[m][0] * self.mode_shape(m, x))
468            .sum()
469    }
470    /// Set control gains to avoid spillover (zero out high modes).
471    pub fn set_control_gains(&mut self, gains: Vec<f64>) {
472        let n = gains.len().min(self.n_modes);
473        self.control_gains[..n].copy_from_slice(&gains[..n]);
474    }
475}
476/// Hydrogel volume change from solvent absorption using Flory-Rehner theory.
477///
478/// Models equilibrium swelling: chemical potential of mixing + elastic penalty = 0.
479#[derive(Debug, Clone, Copy)]
480pub struct HydrogelSwelling {
481    /// Flory-Huggins interaction parameter χ.
482    pub chi: f64,
483    /// Polymer volume fraction at synthesis (reference state) φ_0.
484    pub phi0: f64,
485    /// Number of monomers per network strand N_c (crosslink-related).
486    pub n_c: f64,
487    /// Molar volume of solvent \[m³/mol\].
488    pub v_s: f64,
489    /// Temperature \[K\].
490    pub temperature: f64,
491}
492impl HydrogelSwelling {
493    /// Create a hydrogel swelling model.
494    pub fn new(chi: f64, phi0: f64, n_c: f64, v_s: f64, temperature: f64) -> Self {
495        Self {
496            chi,
497            phi0,
498            n_c,
499            v_s,
500            temperature,
501        }
502    }
503    /// Standard poly(N-isopropylacrylamide) (PNIPAM) hydrogel model.
504    pub fn pnipam() -> Self {
505        Self::new(0.45, 0.05, 100.0, 18e-6, 298.0)
506    }
507    /// Flory-Huggins mixing free energy density \[J/m³\].
508    ///
509    /// f_mix = (RT/V_s) * \[φ ln φ + (1-φ) ln(1-φ) + χ φ (1-φ)\]
510    pub fn mixing_free_energy(&self, phi: f64) -> f64 {
511        const R: f64 = 8.314;
512        let phi = phi.clamp(1e-6, 1.0 - 1e-6);
513        let psi = 1.0 - phi;
514        (R * self.temperature / self.v_s) * (phi * phi.ln() + psi * psi.ln() + self.chi * phi * psi)
515    }
516    /// Equilibrium polymer volume fraction φ_eq.
517    ///
518    /// Solved iteratively: chemical potential = 0.
519    pub fn equilibrium_phi(&self) -> f64 {
520        let phi_guess = self.phi0;
521        let mut phi = phi_guess.clamp(0.01, 0.99);
522        for _ in 0..50 {
523            let psi = 1.0 - phi;
524            let mu_mix = phi.ln() + psi + self.chi * psi * psi;
525            let mu_el = (phi / (2.0 * self.n_c)) * (phi / self.phi0).powf(1.0 / 3.0);
526            let mu = mu_mix + mu_el;
527            let d_mu_mix = 1.0 / phi - 1.0 - 2.0 * self.chi * psi;
528            let d_mu_el = (1.0 / (2.0 * self.n_c))
529                * (phi / self.phi0).powf(1.0 / 3.0)
530                * (1.0 + phi / (3.0 * self.phi0));
531            let d_mu = d_mu_mix + d_mu_el;
532            if d_mu.abs() < 1e-15 {
533                break;
534            }
535            phi -= mu / d_mu;
536            phi = phi.clamp(0.001, 0.999);
537        }
538        phi
539    }
540    /// Equilibrium swelling ratio Q = V_swollen / V_dry = 1/φ_eq.
541    pub fn swelling_ratio(&self) -> f64 {
542        1.0 / self.equilibrium_phi().max(1e-6)
543    }
544    /// Linear swelling strain ε = Q^(1/3) - 1.
545    pub fn linear_strain(&self) -> f64 {
546        self.swelling_ratio().powf(1.0 / 3.0) - 1.0
547    }
548    /// Osmotic pressure \[Pa\] at volume fraction φ.
549    pub fn osmotic_pressure(&self, phi: f64) -> f64 {
550        const R: f64 = 8.314;
551        let phi = phi.clamp(1e-6, 1.0 - 1e-6);
552        let psi = 1.0 - phi;
553        -(R * self.temperature / self.v_s) * (psi + phi.ln() + self.chi * phi * phi)
554    }
555}
556/// Type of electroactive polymer.
557#[derive(Debug, Clone, Copy, PartialEq)]
558pub enum EapType {
559    /// Ionic EAP: actuation by ion migration (IPMC, conductive polymer).
560    Ionic,
561    /// Electronic EAP: actuation by electrostatic forces (dielectric elastomer, PVDF).
562    Electronic,
563}
564/// Ferroelectric P-E hysteresis loop using a simplified Preisach model.
565///
566/// The Preisach model represents the polarization as a superposition of
567/// rectangular hysterons. Here we use a discretized 1D grid of hysterons.
568#[derive(Debug, Clone)]
569pub struct FerroelectricHysteresis {
570    /// Saturation polarization P_sat \[C/m²\].
571    pub p_sat: f64,
572    /// Coercive field E_c \[V/m\].
573    pub e_coercive: f64,
574    /// Remanent polarization P_r \[C/m²\].
575    pub p_remanent: f64,
576    /// Number of Preisach hysteron bins.
577    pub n_bins: usize,
578    /// Hysteron states: +1 or -1.
579    pub(super) hysteron_states: Vec<i8>,
580    /// Hysteron switching fields (up/down) pairs.
581    pub(super) hysteron_fields: Vec<[f64; 2]>,
582    /// Current polarization \[C/m²\].
583    pub polarization: f64,
584}
585impl FerroelectricHysteresis {
586    /// Create a ferroelectric hysteresis model.
587    pub fn new(p_sat: f64, e_coercive: f64, p_remanent: f64, n_bins: usize) -> Self {
588        let mut hysteron_fields = Vec::with_capacity(n_bins);
589        let mut hysteron_states = Vec::with_capacity(n_bins);
590        for i in 0..n_bins {
591            let frac = (i as f64 + 0.5) / n_bins as f64;
592            let e_up = e_coercive * (0.5 + 1.5 * frac);
593            let e_down = -e_up * (p_remanent / p_sat).max(0.1);
594            hysteron_fields.push([e_down, e_up]);
595            hysteron_states.push(-1i8);
596        }
597        Self {
598            p_sat,
599            e_coercive,
600            p_remanent,
601            n_bins,
602            hysteron_states,
603            hysteron_fields,
604            polarization: -p_remanent,
605        }
606    }
607    /// Standard BaTiO3 model.
608    pub fn barium_titanate() -> Self {
609        Self::new(0.26, 0.4e6, 0.18, 32)
610    }
611    /// Update polarization for applied field E \[V/m\].
612    pub fn update(&mut self, e_field: f64) {
613        for (i, &[e_down, e_up]) in self.hysteron_fields.iter().enumerate() {
614            if e_field > e_up {
615                self.hysteron_states[i] = 1;
616            } else if e_field < e_down {
617                self.hysteron_states[i] = -1;
618            }
619        }
620        let sum: i32 = self.hysteron_states.iter().map(|&s| s as i32).sum();
621        self.polarization = self.p_sat * sum as f64 / self.n_bins as f64;
622    }
623    /// Susceptibility at current state (finite difference).
624    pub fn susceptibility(&self, e_field: f64, delta_e: f64) -> f64 {
625        let mut twin = self.clone();
626        twin.update(e_field + delta_e);
627        let p1 = twin.polarization;
628        let mut twin2 = self.clone();
629        twin2.update(e_field - delta_e);
630        let p2 = twin2.polarization;
631        (p1 - p2) / (2.0 * delta_e)
632    }
633    /// Dielectric energy stored \[J/m³\].
634    pub fn stored_energy(&self, e_field: f64) -> f64 {
635        const EPS0: f64 = 8.854e-12;
636        EPS0 * e_field * e_field / 2.0 + self.polarization * e_field
637    }
638}
639/// Bimaterial beam (bimorph) actuated by differential thermal expansion.
640#[derive(Debug, Clone, Copy)]
641pub struct BimorphActuator {
642    /// Length of beam \[m\].
643    pub length: f64,
644    /// Thickness of layer 1 \[m\].
645    pub t1: f64,
646    /// Thickness of layer 2 \[m\].
647    pub t2: f64,
648    /// Elastic modulus of layer 1 \[Pa\].
649    pub e1: f64,
650    /// Elastic modulus of layer 2 \[Pa\].
651    pub e2: f64,
652    /// Thermal expansion of layer 1 \[1/K\].
653    pub alpha1: f64,
654    /// Thermal expansion of layer 2 \[1/K\].
655    pub alpha2: f64,
656}
657impl BimorphActuator {
658    /// Create a bimorph actuator.
659    pub fn new(length: f64, t1: f64, t2: f64, e1: f64, e2: f64, alpha1: f64, alpha2: f64) -> Self {
660        Self {
661            length,
662            t1,
663            t2,
664            e1,
665            e2,
666            alpha1,
667            alpha2,
668        }
669    }
670    /// Tip deflection for temperature change ΔT \[m\].
671    /// Uses Timoshenko bimetal formula.
672    pub fn tip_deflection(&self, delta_t: f64) -> f64 {
673        let m = self.e1 * self.t1 / (self.e2 * self.t2);
674        let n = self.t1 / self.t2;
675        let t_total = self.t1 + self.t2;
676        let denom = 3.0 * (1.0 + m) * (1.0 + m) + (1.0 + m * n) * (m * m + 1.0 / (m * n));
677        if denom.abs() < 1e-20 {
678            return 0.0;
679        }
680        let curvature = 6.0 * (self.alpha1 - self.alpha2) * delta_t * (1.0 + m) / (t_total * denom);
681        curvature * self.length * self.length / 2.0
682    }
683    /// Neutral axis position from layer 1 bottom \[m\].
684    pub fn neutral_axis(&self) -> f64 {
685        let n1 = self.e1 * self.t1;
686        let n2 = self.e2 * self.t2;
687        (n1 * self.t1 / 2.0 + n2 * (self.t1 + self.t2 / 2.0)) / (n1 + n2)
688    }
689    /// Curvature radius at ΔT \[m\].
690    pub fn curvature_radius(&self, delta_t: f64) -> f64 {
691        let deflection = self.tip_deflection(delta_t);
692        if deflection.abs() < 1e-20 {
693            return f64::INFINITY;
694        }
695        self.length * self.length / (2.0 * deflection)
696    }
697}
698/// Layered smart composite: SMA layer embedded in elastic matrix.
699///
700/// Computes effective thermo-mechanical properties using rule-of-mixtures
701/// (Voigt/Reuss bounds) for a unidirectional SMA fiber composite.
702#[derive(Debug, Clone)]
703pub struct SmartComposite {
704    /// SMA fiber volume fraction (0..1).
705    pub fiber_volume_fraction: f64,
706    /// SMA constitutive model.
707    pub sma: ShapeMemoryAlloy,
708    /// Matrix elastic modulus \[Pa\].
709    pub matrix_modulus: f64,
710    /// Matrix density \[kg/m³\].
711    pub matrix_density: f64,
712    /// SMA density \[kg/m³\].
713    pub sma_density: f64,
714    /// Total thickness \[m\].
715    pub thickness: f64,
716}
717impl SmartComposite {
718    /// Create a smart composite.
719    pub fn new(
720        fiber_volume_fraction: f64,
721        sma: ShapeMemoryAlloy,
722        matrix_modulus: f64,
723        matrix_density: f64,
724        sma_density: f64,
725        thickness: f64,
726    ) -> Self {
727        Self {
728            fiber_volume_fraction,
729            sma,
730            matrix_modulus,
731            matrix_density,
732            sma_density,
733            thickness,
734        }
735    }
736    /// Effective longitudinal modulus (Voigt rule of mixtures) \[Pa\].
737    pub fn longitudinal_modulus(&self) -> f64 {
738        let v_f = self.fiber_volume_fraction;
739        let v_m = 1.0 - v_f;
740        v_f * self.sma.elastic_modulus() + v_m * self.matrix_modulus
741    }
742    /// Effective transverse modulus (Reuss rule of mixtures) \[Pa\].
743    pub fn transverse_modulus(&self) -> f64 {
744        let v_f = self.fiber_volume_fraction;
745        let v_m = 1.0 - v_f;
746        1.0 / (v_f / self.sma.elastic_modulus() + v_m / self.matrix_modulus)
747    }
748    /// Effective density \[kg/m³\].
749    pub fn density(&self) -> f64 {
750        let v_f = self.fiber_volume_fraction;
751        let v_m = 1.0 - v_f;
752        v_f * self.sma_density + v_m * self.matrix_density
753    }
754    /// Recovery stress of the composite from SMA activation \[Pa\].
755    pub fn composite_recovery_stress(&self, strain: f64) -> f64 {
756        self.fiber_volume_fraction * self.sma.recovery_stress(strain)
757    }
758    /// Actuation curvature of an asymmetric laminate from thermal loading \[1/m\].
759    ///
760    /// Simplified: assumes SMA layer on one side and matrix on the other.
761    pub fn actuation_curvature(&self, temperature: f64) -> f64 {
762        let e_sma = self.sma.elastic_modulus();
763        let e_mat = self.matrix_modulus;
764        let t_sma = self.thickness * self.fiber_volume_fraction;
765        let t_mat = self.thickness * (1.0 - self.fiber_volume_fraction);
766        let eps_sma = self.sma.max_strain * (1.0 - self.sma.xi);
767        let _ = temperature;
768
769        6.0 * e_sma * e_mat * t_sma * t_mat * (t_sma + t_mat) * eps_sma
770            / (e_sma * t_sma.powi(2) * (4.0 * t_sma + 3.0 * t_mat)
771                + e_mat * t_mat.powi(2) * (4.0 * t_mat + 3.0 * t_sma)
772                + 6.0 * e_sma * e_mat * t_sma * t_mat * (t_sma + t_mat))
773                .max(1e-30)
774    }
775}
776/// Brinson constitutive model for shape memory alloys.
777///
778/// Explicitly tracks stress-induced martensite (ξ_s) and
779/// temperature-induced martensite (ξ_T) fractions, enabling full
780/// simulation of:
781/// - Shape Memory Effect (SME): thermally driven full recovery
782/// - Superelasticity: stress-driven reversible strain
783#[derive(Debug, Clone)]
784#[allow(dead_code)]
785pub struct BrinsonModel {
786    /// Martensite start temperature \[K\].
787    pub ms: f64,
788    /// Martensite finish temperature \[K\].
789    pub mf: f64,
790    /// Austenite start temperature \[K\].
791    pub a_s: f64,
792    /// Austenite finish temperature \[K\].
793    pub af: f64,
794    /// Stress-induced martensite fraction ξ_s ∈ \[0, 1\].
795    pub xi_s: f64,
796    /// Temperature-induced martensite fraction ξ_T ∈ \[0, 1\].
797    pub xi_t: f64,
798    /// Austenite elastic modulus \[Pa\].
799    pub e_a: f64,
800    /// Martensite elastic modulus \[Pa\].
801    pub e_m: f64,
802    /// Maximum recoverable transformation strain ε_L.
803    pub eps_l: f64,
804    /// Stress influence coefficient for M transformation \[Pa/K\].
805    pub cm: f64,
806    /// Stress influence coefficient for A transformation \[Pa/K\].
807    pub ca: f64,
808    /// Thermoelastic coefficient Θ \[Pa/K\].
809    pub theta: f64,
810    /// Current stress \[Pa\].
811    pub stress: f64,
812    /// Current strain.
813    pub strain: f64,
814    /// Temperature \[K\].
815    pub temperature: f64,
816}
817impl BrinsonModel {
818    /// Create a Brinson model.
819    #[allow(clippy::too_many_arguments)]
820    pub fn new(
821        ms: f64,
822        mf: f64,
823        a_s: f64,
824        af: f64,
825        e_a: f64,
826        e_m: f64,
827        eps_l: f64,
828        cm: f64,
829        ca: f64,
830        theta: f64,
831    ) -> Self {
832        Self {
833            ms,
834            mf,
835            a_s,
836            af,
837            xi_s: 0.0,
838            xi_t: 1.0,
839            e_a,
840            e_m,
841            eps_l,
842            cm,
843            ca,
844            theta,
845            stress: 0.0,
846            strain: 0.0,
847            temperature: mf,
848        }
849    }
850    /// Create a standard NiTi model (Brinson parameters).
851    pub fn nitinol() -> Self {
852        Self::new(
853            291.0, 273.0, 307.0, 325.0, 75e9, 28e9, 0.08, 8e6, 13e6, 0.55e6,
854        )
855    }
856    /// Total martensite fraction ξ = ξ_s + ξ_T.
857    pub fn total_xi(&self) -> f64 {
858        (self.xi_s + self.xi_t).clamp(0.0, 1.0)
859    }
860    /// Effective elastic modulus E(ξ) = E_A + ξ(E_M − E_A).
861    pub fn elastic_modulus(&self) -> f64 {
862        self.e_a + self.total_xi() * (self.e_m - self.e_a)
863    }
864    /// Update phase fractions from temperature and stress using Brinson kinetics.
865    ///
866    /// Returns `(ξ_s, ξ_T, ξ_total)` after update.
867    pub fn update(&mut self, temperature: f64, stress: f64) -> (f64, f64, f64) {
868        self.temperature = temperature;
869        self.stress = stress;
870        let ms_s = self.ms + stress / self.cm;
871        let mf_s = self.mf + stress / self.cm;
872        if temperature <= ms_s && temperature >= mf_s {
873            let cos_arg = PI * (temperature - ms_s) / (mf_s - ms_s);
874            let xi_new = (1.0 + cos_arg.cos()) / 2.0;
875            if xi_new > self.xi_t {
876                self.xi_t = xi_new.min(1.0 - self.xi_s);
877            }
878        } else if temperature < mf_s {
879            self.xi_t = 1.0 - self.xi_s;
880        }
881        let sigma_ms = self.cm * (temperature - self.ms).max(0.0);
882        let sigma_mf = self.cm * (temperature - self.mf).max(0.0);
883        if stress >= sigma_ms && stress <= sigma_mf + 1e-3 && temperature > self.mf {
884            let cos_arg = PI * (stress - sigma_ms) / (sigma_mf - sigma_ms + 1e-6);
885            let xi_s_new = (1.0 - self.xi_s) / 2.0 * (1.0 - cos_arg.cos());
886            self.xi_s = (self.xi_s + xi_s_new).clamp(0.0, 1.0 - self.xi_t);
887        }
888        let as_s = self.a_s + stress / self.ca;
889        let af_s = self.af + stress / self.ca;
890        if temperature >= as_s && temperature <= af_s {
891            let cos_arg = PI * (temperature - as_s) / (af_s - as_s);
892            let xi_new = self.total_xi() / 2.0 * (1.0 + cos_arg.cos());
893            let xi_total = xi_new.clamp(0.0, self.total_xi());
894            let frac = if self.total_xi() > 1e-10 {
895                xi_total / self.total_xi()
896            } else {
897                0.0
898            };
899            self.xi_s = (self.xi_s * frac).clamp(0.0, 1.0);
900            self.xi_t = (self.xi_t * frac).clamp(0.0, 1.0);
901        } else if temperature > af_s {
902            self.xi_s = 0.0;
903            self.xi_t = 0.0;
904        }
905        (self.xi_s, self.xi_t, self.total_xi())
906    }
907    /// Compute stress from strain using Brinson constitutive law.
908    ///
909    /// σ = E(ξ)·ε − E(ξ)·ε_L·ξ_s + Θ·(T − T_ref)
910    pub fn stress_from_strain(&self, strain: f64, t_ref: f64) -> f64 {
911        let e = self.elastic_modulus();
912        e * (strain - self.eps_l * self.xi_s) + self.theta * (self.temperature - t_ref)
913    }
914    /// Recovery stress when cooled from austenite to martensite at fixed strain \[Pa\].
915    pub fn recovery_stress(&self, strain: f64) -> f64 {
916        self.elastic_modulus() * (strain - self.eps_l * self.xi_s)
917    }
918    /// SME stroke: change in length from xi_initial to xi_final \[m\] for wire of length L.
919    pub fn sme_stroke(&self, xi_initial: f64, xi_final: f64, wire_length: f64) -> f64 {
920        let delta_xi_s = xi_initial - xi_final;
921        delta_xi_s * self.eps_l * wire_length
922    }
923}
924/// Extended magnetorheological fluid model including particle chain formation.
925///
926/// Uses Bingham plastic with field-dependent yield stress derived from:
927/// 1. Mason number analysis
928/// 2. Chain formation model (dipole-dipole interactions)
929/// 3. Off-state viscosity correction with volume fraction (Krieger-Dougherty)
930#[derive(Debug, Clone, Copy)]
931#[allow(dead_code)]
932pub struct Magnetorheological {
933    /// Off-state dynamic viscosity η_0 \[Pa·s\].
934    pub eta0: f64,
935    /// Magnetic permeability of carrier fluid \[H/m\].
936    pub mu_c: f64,
937    /// Saturation magnetization of particles M_s \[A/m\].
938    pub m_sat: f64,
939    /// Particle volume fraction φ.
940    pub phi: f64,
941    /// Field-exponent for yield stress correlation.
942    pub n_exp: f64,
943    /// Empirical yield stress coefficient c_y \[Pa·(m/A)^n\].
944    pub c_yield: f64,
945    /// Maximum packing fraction φ_m.
946    pub phi_max: f64,
947    /// Intrinsic viscosity \[η\] for K-D equation.
948    pub intrinsic_viscosity: f64,
949}
950impl Magnetorheological {
951    /// Create an MR fluid model.
952    #[allow(clippy::too_many_arguments)]
953    pub fn new(
954        eta0: f64,
955        mu_c: f64,
956        m_sat: f64,
957        phi: f64,
958        n_exp: f64,
959        c_yield: f64,
960        phi_max: f64,
961        intrinsic_viscosity: f64,
962    ) -> Self {
963        Self {
964            eta0,
965            mu_c,
966            m_sat,
967            phi,
968            n_exp,
969            c_yield,
970            phi_max,
971            intrinsic_viscosity,
972        }
973    }
974    /// Standard carbonyl iron / silicone oil MR fluid.
975    pub fn carbonyl_iron() -> Self {
976        Self::new(0.1, 4.0 * PI * 1e-7, 1.36e6, 0.30, 1.5, 0.3e3, 0.74, 2.5)
977    }
978    /// Effective off-state viscosity using Krieger-Dougherty model.
979    pub fn effective_viscosity_off(&self) -> f64 {
980        let ratio = self.phi / self.phi_max;
981        let kd = (1.0 - ratio).powf(-self.intrinsic_viscosity * self.phi_max);
982        self.eta0 * kd.max(1.0)
983    }
984    /// Field-dependent yield stress τ_y(H) \[Pa\].
985    pub fn yield_stress(&self, h_field: f64) -> f64 {
986        self.c_yield * h_field.powf(self.n_exp)
987    }
988    /// Mason number Mn = η·γ̇ / (μ_0 H²).
989    pub fn mason_number(&self, shear_rate: f64, h_field: f64) -> f64 {
990        const MU0: f64 = 1.2566370614e-6;
991        if h_field < 1e-10 {
992            return f64::INFINITY;
993        }
994        self.eta0 * shear_rate / (MU0 * h_field * h_field)
995    }
996    /// Shear stress using Bingham model: τ = τ_y(H) + η_eff · γ̇.
997    pub fn shear_stress(&self, shear_rate: f64, h_field: f64) -> f64 {
998        let tau_y = self.yield_stress(h_field);
999        let eta_eff = self.effective_viscosity_off();
1000        if shear_rate.abs() < 1e-12 {
1001            return tau_y;
1002        }
1003        tau_y + eta_eff * shear_rate
1004    }
1005    /// Apparent viscosity η_app = τ / γ̇ \[Pa·s\].
1006    pub fn apparent_viscosity(&self, shear_rate: f64, h_field: f64) -> f64 {
1007        if shear_rate.abs() < 1e-12 {
1008            return f64::INFINITY;
1009        }
1010        self.shear_stress(shear_rate, h_field) / shear_rate
1011    }
1012    /// Number of chains formed per unit volume (simplified model).
1013    ///
1014    /// N_chains ∝ φ / d³ where d is particle diameter.
1015    /// Returns a dimensionless chain fraction estimate.
1016    pub fn chain_fraction(&self, h_field: f64) -> f64 {
1017        const MU0: f64 = 1.2566370614e-6;
1018        let m = MU0 * self.m_sat * h_field;
1019        let lambda = m / (self.eta0.max(1e-10) + 1.0);
1020        (lambda * self.phi).tanh().clamp(0.0, 1.0)
1021    }
1022    /// Relative viscosity enhancement due to chain formation.
1023    ///
1024    /// Returns η_eff / η_0.
1025    pub fn viscosity_ratio(&self, h_field: f64) -> f64 {
1026        let cf = self.chain_fraction(h_field);
1027        1.0 + 100.0 * cf * self.phi
1028    }
1029}
1030/// Phase of a shape memory alloy.
1031#[derive(Debug, Clone, Copy, PartialEq)]
1032pub enum SmaPhase {
1033    /// Fully martensitic (low temperature, high martensite fraction).
1034    Martensite,
1035    /// Fully austenitic (high temperature, low martensite fraction).
1036    Austenite,
1037    /// Mixed phase transformation region.
1038    Mixed,
1039}
1040/// Electroactive polymer (EAP) model.
1041///
1042/// Covers both ionic (low voltage, large bending) and electronic (high voltage,
1043/// large area strain) EAP types.
1044#[derive(Debug, Clone)]
1045pub struct ElectroactivePoly {
1046    /// Type of EAP.
1047    pub eap_type: EapType,
1048    /// Maximum actuation strain (dimensionless).
1049    pub max_strain: f64,
1050    /// Half-wave voltage V_50 at which strain = 50 % max \[V\].
1051    pub v_half: f64,
1052    /// Elastic modulus \[Pa\].
1053    pub elastic_modulus: f64,
1054    /// Bandwidth (time constant) \[s\].
1055    pub time_constant: f64,
1056    /// Current actuation state (0..1).
1057    pub state: f64,
1058}
1059impl ElectroactivePoly {
1060    /// Create an EAP model.
1061    pub fn new(
1062        eap_type: EapType,
1063        max_strain: f64,
1064        v_half: f64,
1065        elastic_modulus: f64,
1066        time_constant: f64,
1067    ) -> Self {
1068        Self {
1069            eap_type,
1070            max_strain,
1071            v_half,
1072            elastic_modulus,
1073            time_constant,
1074            state: 0.0,
1075        }
1076    }
1077    /// Standard IPMC model (ionic).
1078    pub fn ipmc() -> Self {
1079        Self::new(EapType::Ionic, 0.03, 1.5, 1e6, 0.1)
1080    }
1081    /// Standard dielectric elastomer model (electronic).
1082    pub fn dielectric_elastomer() -> Self {
1083        Self::new(EapType::Electronic, 0.45, 1000.0, 1e5, 0.001)
1084    }
1085    /// Steady-state strain from applied voltage (sigmoid model).
1086    pub fn steady_state_strain(&self, voltage: f64) -> f64 {
1087        let k = 4.0 / self.v_half;
1088        let s = 1.0 / (1.0 + (-k * (voltage - self.v_half)).exp());
1089        self.max_strain * s
1090    }
1091    /// Transient strain at time t after step voltage.
1092    pub fn transient_strain(&self, voltage: f64, t: f64) -> f64 {
1093        let ss = self.steady_state_strain(voltage);
1094        ss * (1.0 - (-t / self.time_constant).exp())
1095    }
1096    /// Blocking stress (force per area) \[Pa\].
1097    pub fn blocking_stress(&self, voltage: f64) -> f64 {
1098        self.elastic_modulus * self.steady_state_strain(voltage)
1099    }
1100    /// Update state (first-order lag) by dt.
1101    pub fn update(&mut self, voltage: f64, dt: f64) {
1102        let target = self.steady_state_strain(voltage) / self.max_strain.max(1e-12);
1103        let tau = self.time_constant;
1104        self.state += (target - self.state) * (1.0 - (-dt / tau).exp());
1105        self.state = self.state.clamp(0.0, 1.0);
1106    }
1107    /// Current strain from state.
1108    pub fn current_strain(&self) -> f64 {
1109        self.state * self.max_strain
1110    }
1111}
1112/// Extended hydrogel swelling model using full Flory-Rehner theory.
1113///
1114/// Models the equilibrium between:
1115/// - Mixing free energy (Flory-Huggins): ΔF_mix = kT\[φ ln φ + (1-φ) ln(1-φ) + χφ(1-φ)\]
1116/// - Elastic free energy (affine network): ΔF_el = (3νkT/2)(λ² − 1 − 2 ln λ)
1117///
1118/// where φ is polymer volume fraction and λ = Q^(1/3) is the stretch ratio.
1119#[derive(Debug, Clone, Copy)]
1120#[allow(dead_code)]
1121pub struct HydrogelFloryRehner {
1122    /// Flory-Huggins interaction parameter χ (dimensionless).
1123    pub chi: f64,
1124    /// Effective cross-link density ν \[mol/m³\].
1125    pub crosslink_density: f64,
1126    /// Polymer volume fraction at synthesis φ₀.
1127    pub phi0: f64,
1128    /// Molar volume of solvent \[m³/mol\].
1129    pub v_s: f64,
1130    /// Temperature \[K\].
1131    pub temperature: f64,
1132    /// Elastic modulus of the dry network \[Pa\].
1133    pub dry_modulus: f64,
1134}
1135impl HydrogelFloryRehner {
1136    /// Create a Flory-Rehner hydrogel model.
1137    pub fn new(
1138        chi: f64,
1139        crosslink_density: f64,
1140        phi0: f64,
1141        v_s: f64,
1142        temperature: f64,
1143        dry_modulus: f64,
1144    ) -> Self {
1145        Self {
1146            chi,
1147            crosslink_density,
1148            phi0,
1149            v_s,
1150            temperature,
1151            dry_modulus,
1152        }
1153    }
1154    /// Standard polyacrylamide hydrogel.
1155    pub fn polyacrylamide() -> Self {
1156        Self::new(0.45, 10.0, 0.05, 18e-6, 298.0, 1e4)
1157    }
1158    /// Mixing free energy density \[J/m³\] at volume fraction φ.
1159    pub fn mixing_free_energy(&self, phi: f64) -> f64 {
1160        const R: f64 = 8.314;
1161        let phi = phi.clamp(1e-6, 1.0 - 1e-6);
1162        let psi = 1.0 - phi;
1163        R * self.temperature / self.v_s * (phi * phi.ln() + psi * psi.ln() + self.chi * phi * psi)
1164    }
1165    /// Elastic free energy density \[J/m³\] at volume fraction φ.
1166    ///
1167    /// Uses affine network model: f_el = ν kT / 2 · (3λ² − 3 − 2 ln λ³)
1168    pub fn elastic_free_energy(&self, phi: f64) -> f64 {
1169        const R: f64 = 8.314;
1170        let phi = phi.clamp(1e-6, 1.0);
1171        let lambda = (self.phi0 / phi).powf(1.0 / 3.0);
1172        let lambda2 = lambda * lambda;
1173        let ln_lambda3 = 3.0 * lambda.ln();
1174        self.crosslink_density * R * self.temperature / 2.0
1175            * (3.0 * lambda2 - 3.0 - 2.0 * ln_lambda3)
1176    }
1177    /// Total free energy density \[J/m³\].
1178    pub fn total_free_energy(&self, phi: f64) -> f64 {
1179        self.mixing_free_energy(phi) + self.elastic_free_energy(phi)
1180    }
1181    /// Chemical potential of solvent μ (dimensionless, in kT units).
1182    ///
1183    /// μ = ∂(V_s · f_mix) / ∂(1-φ)
1184    pub fn chemical_potential(&self, phi: f64) -> f64 {
1185        let phi = phi.clamp(1e-6, 1.0 - 1e-6);
1186        let psi = 1.0 - phi;
1187        psi.ln() + phi + self.chi * phi * phi
1188    }
1189    /// Elastic chemical potential contribution from network elasticity.
1190    pub fn elastic_chemical_potential(&self, phi: f64) -> f64 {
1191        let phi = phi.clamp(1e-6, 1.0);
1192        let n = (self.crosslink_density * self.v_s).max(1e-10);
1193        (phi / (2.0 * n)) * (phi / self.phi0).powf(1.0 / 3.0)
1194    }
1195    /// Find equilibrium swelling ratio Q = V_wet / V_dry by Newton iteration.
1196    pub fn equilibrium_swelling(&self) -> f64 {
1197        let mut phi = self.phi0.clamp(0.01, 0.99);
1198        for _ in 0..100 {
1199            let mu_mix = self.chemical_potential(phi);
1200            let mu_el = self.elastic_chemical_potential(phi);
1201            let mu = mu_mix + mu_el;
1202            let dphi = 1e-6;
1203            let d_mu = (self.chemical_potential(phi + dphi)
1204                + self.elastic_chemical_potential(phi + dphi)
1205                - self.chemical_potential(phi - dphi)
1206                - self.elastic_chemical_potential(phi - dphi))
1207                / (2.0 * dphi);
1208            if d_mu.abs() < 1e-15 {
1209                break;
1210            }
1211            phi -= mu / d_mu;
1212            phi = phi.clamp(0.001, 0.999);
1213        }
1214        1.0 / phi.max(1e-6)
1215    }
1216    /// Linear swelling strain ε = Q^(1/3) − 1.
1217    pub fn linear_strain(&self) -> f64 {
1218        self.equilibrium_swelling().powf(1.0 / 3.0) - 1.0
1219    }
1220    /// Osmotic pressure Π = −∂f_total/∂V at given φ \[Pa\].
1221    pub fn osmotic_pressure(&self, phi: f64) -> f64 {
1222        const R: f64 = 8.314;
1223        let phi = phi.clamp(1e-6, 1.0 - 1e-6);
1224        let psi = 1.0 - phi;
1225        let mu1 = psi.ln() + phi + self.chi * phi * phi;
1226        -(R * self.temperature / self.v_s) * mu1
1227    }
1228}
1229/// Self-healing material model.
1230///
1231/// Models:
1232/// - Damage-triggered healing kinetics (first-order reaction)
1233/// - Healing efficiency η_h as a function of time and temperature
1234/// - Strength recovery: σ_recovery(t) = σ₀ · η_h(t)
1235/// - Activation energy dependence (Arrhenius kinetics)
1236#[derive(Debug, Clone)]
1237#[allow(dead_code)]
1238pub struct SelfHealingMaterial {
1239    /// Undamaged tensile strength σ₀ \[Pa\].
1240    pub virgin_strength: f64,
1241    /// Current damage fraction D ∈ \[0, 1\].
1242    pub damage: f64,
1243    /// Cumulative healing fraction η_h ∈ \[0, 1\].
1244    pub healing_efficiency: f64,
1245    /// Healing kinetics rate constant k_h \[1/s\] at reference temperature.
1246    pub k_healing: f64,
1247    /// Activation energy for healing reaction \[J/mol\].
1248    pub activation_energy: f64,
1249    /// Reference temperature for k_h \[K\].
1250    pub t_ref: f64,
1251    /// Healing agent volume fraction (0..1).
1252    pub agent_fraction: f64,
1253    /// Critical damage for healing activation (threshold D > D_c triggers healing).
1254    pub damage_threshold: f64,
1255    /// Time elapsed since damage \[s\].
1256    pub time_since_damage: f64,
1257    /// Maximum achievable healing efficiency (intrinsic limit).
1258    pub max_efficiency: f64,
1259}
1260impl SelfHealingMaterial {
1261    /// Create a self-healing material model.
1262    #[allow(clippy::too_many_arguments)]
1263    pub fn new(
1264        virgin_strength: f64,
1265        k_healing: f64,
1266        activation_energy: f64,
1267        t_ref: f64,
1268        agent_fraction: f64,
1269        damage_threshold: f64,
1270        max_efficiency: f64,
1271    ) -> Self {
1272        Self {
1273            virgin_strength,
1274            damage: 0.0,
1275            healing_efficiency: 0.0,
1276            k_healing,
1277            activation_energy,
1278            t_ref,
1279            agent_fraction,
1280            damage_threshold,
1281            time_since_damage: 0.0,
1282            max_efficiency,
1283        }
1284    }
1285    /// Standard microencapsulated epoxy self-healing composite.
1286    pub fn microencapsulated_epoxy() -> Self {
1287        Self::new(60e6, 1e-4, 50e3, 298.0, 0.10, 0.05, 0.90)
1288    }
1289    /// Arrhenius rate constant at temperature T \[K\].
1290    pub fn healing_rate(&self, temperature: f64) -> f64 {
1291        const R: f64 = 8.314;
1292        self.k_healing
1293            * (-self.activation_energy / (R * temperature)).exp()
1294            * (-self.activation_energy / (R * self.t_ref)).exp().recip()
1295    }
1296    /// Apply damage D_new (D ∈ \[0, 1\]) to the material.
1297    pub fn apply_damage(&mut self, damage: f64) {
1298        self.damage = (self.damage + damage).clamp(0.0, 1.0);
1299        if self.damage > self.damage_threshold {
1300            self.time_since_damage = 0.0;
1301            self.healing_efficiency = 0.0;
1302        }
1303    }
1304    /// Advance healing by time `dt` at temperature `temperature`.
1305    ///
1306    /// Updates healing efficiency using first-order kinetics:
1307    /// dη/dt = k_h(T) · (η_max − η) · agent_fraction
1308    pub fn heal(&mut self, dt: f64, temperature: f64) {
1309        if self.damage < self.damage_threshold {
1310            return;
1311        }
1312        let k = self.healing_rate(temperature);
1313        let rate = k * (self.max_efficiency - self.healing_efficiency) * self.agent_fraction;
1314        self.healing_efficiency =
1315            (self.healing_efficiency + rate * dt).clamp(0.0, self.max_efficiency);
1316        self.time_since_damage += dt;
1317    }
1318    /// Current tensile strength \[Pa\] accounting for damage and healing.
1319    ///
1320    /// σ = σ₀ · (1 − D) · (1 + η_h · D)
1321    pub fn current_strength(&self) -> f64 {
1322        let intact_fraction = 1.0 - self.damage;
1323        let healed_contribution = self.healing_efficiency * self.damage;
1324        self.virgin_strength * (intact_fraction + healed_contribution).clamp(0.0, 1.0)
1325    }
1326    /// Effective stiffness reduction factor (CDM approach).
1327    ///
1328    /// E_eff / E₀ = 1 − D · (1 − η_h)
1329    pub fn stiffness_reduction(&self) -> f64 {
1330        (1.0 - self.damage * (1.0 - self.healing_efficiency)).clamp(0.0, 1.0)
1331    }
1332    /// Fracture toughness recovery ratio K_IC / K_IC0.
1333    ///
1334    /// Approximated as sqrt of stiffness reduction (Irwin relation).
1335    pub fn toughness_recovery(&self) -> f64 {
1336        self.stiffness_reduction().sqrt()
1337    }
1338    /// Healing completion fraction (η_h / η_max).
1339    pub fn healing_progress(&self) -> f64 {
1340        if self.max_efficiency < 1e-10 {
1341            0.0
1342        } else {
1343            self.healing_efficiency / self.max_efficiency
1344        }
1345    }
1346}
1347/// Hydrogel actuator using Flory-Rehner swelling theory.
1348#[derive(Debug, Clone, Copy)]
1349pub struct HydrogelActuator {
1350    /// Flory-Huggins interaction parameter χ (temperature/pH dependent).
1351    pub chi: f64,
1352    /// Cross-link density \[mol/m³\].
1353    pub crosslink_density: f64,
1354    /// Reference swelling ratio Q_ref.
1355    pub q_ref: f64,
1356    /// Temperature coefficient dχ/dT \[1/K\].
1357    pub chi_temp_coeff: f64,
1358    /// pH at which χ has reference value.
1359    pub ph_ref: f64,
1360    /// pH sensitivity of χ.
1361    pub chi_ph_coeff: f64,
1362    /// Elastic modulus \[Pa\].
1363    pub elastic_modulus: f64,
1364}
1365impl HydrogelActuator {
1366    /// Create a hydrogel actuator.
1367    pub fn new(
1368        chi: f64,
1369        crosslink_density: f64,
1370        q_ref: f64,
1371        chi_temp_coeff: f64,
1372        ph_ref: f64,
1373        chi_ph_coeff: f64,
1374        elastic_modulus: f64,
1375    ) -> Self {
1376        Self {
1377            chi,
1378            crosslink_density,
1379            q_ref,
1380            chi_temp_coeff,
1381            ph_ref,
1382            chi_ph_coeff,
1383            elastic_modulus,
1384        }
1385    }
1386    /// Effective Flory parameter at temperature T \[K\] and pH.
1387    pub fn effective_chi(&self, temperature: f64, ph: f64) -> f64 {
1388        self.chi
1389            + self.chi_temp_coeff * (temperature - 298.0)
1390            + self.chi_ph_coeff * (ph - self.ph_ref)
1391    }
1392    /// Swelling ratio Q at given conditions (Flory-Rehner equilibrium, simplified).
1393    /// Q = V_swollen / V_dry.
1394    pub fn swelling_ratio(&self, temperature: f64, ph: f64) -> f64 {
1395        let chi_eff = self.effective_chi(temperature, ph);
1396        let q = self.q_ref * (-chi_eff * 0.5).exp();
1397        q.max(1.0)
1398    }
1399    /// Linear strain (relative to reference) from swelling ratio.
1400    pub fn linear_strain(&self, temperature: f64, ph: f64) -> f64 {
1401        let q = self.swelling_ratio(temperature, ph);
1402        q.powf(1.0 / 3.0) - 1.0
1403    }
1404    /// Swelling pressure (osmotic minus elastic) \[Pa\].
1405    pub fn swelling_pressure(&self, temperature: f64, ph: f64) -> f64 {
1406        let strain = self.linear_strain(temperature, ph);
1407        self.elastic_modulus * strain
1408    }
1409    /// Force generated by hydrogel strip of cross-section area A \[m²\].
1410    pub fn force(&self, temperature: f64, ph: f64, area: f64) -> f64 {
1411        self.swelling_pressure(temperature, ph) * area
1412    }
1413}
1414/// Magnetostrictive material: strain vs applied magnetic field H.
1415///
1416/// Uses a simplified Langevin-based saturation model for Joule magnetostriction.
1417/// λ(H) = λ_sat * \[coth(H/H_sat) - H_sat/H\]³  (modified Langevin)
1418#[derive(Debug, Clone, Copy)]
1419pub struct MagnetostrictiveMaterial {
1420    /// Saturation magnetostriction λ_sat (dimensionless, e.g. 1000e-6 for Terfenol-D).
1421    pub lambda_sat: f64,
1422    /// Characteristic (saturation-scale) field H_sat \[A/m\].
1423    pub h_sat: f64,
1424    /// Young's modulus \[Pa\].
1425    pub elastic_modulus: f64,
1426    /// Magnetomechanical coupling factor d_33 \[m/A\].
1427    pub d33: f64,
1428    /// Relative permeability.
1429    pub mu_r: f64,
1430}
1431impl MagnetostrictiveMaterial {
1432    /// Create a magnetostrictive material model.
1433    pub fn new(lambda_sat: f64, h_sat: f64, elastic_modulus: f64, d33: f64, mu_r: f64) -> Self {
1434        Self {
1435            lambda_sat,
1436            h_sat,
1437            elastic_modulus,
1438            d33,
1439            mu_r,
1440        }
1441    }
1442    /// Standard Terfenol-D model.
1443    pub fn terfenol_d() -> Self {
1444        Self::new(1600e-6, 60e3, 50e9, 20e-9, 3.0)
1445    }
1446    /// Magnetostrictive strain λ at field H \[A/m\] using Langevin function.
1447    pub fn strain(&self, h_field: f64) -> f64 {
1448        if h_field.abs() < 1e-10 {
1449            return 0.0;
1450        }
1451        let x = h_field / self.h_sat;
1452        let l = if x.abs() > 30.0 {
1453            x.signum() * (1.0 - 1.0 / x.abs())
1454        } else {
1455            1.0 / x.tanh() - 1.0 / x
1456        };
1457        self.lambda_sat * l * l * h_field.signum().max(0.0)
1458            + self.lambda_sat * l * l * (-h_field.signum()).max(0.0)
1459    }
1460    /// Magnetostrictive strain with correct even-symmetry (λ ≥ 0 always).
1461    pub fn strain_magnitude(&self, h_field: f64) -> f64 {
1462        if h_field.abs() < 1e-10 {
1463            return 0.0;
1464        }
1465        let x = h_field.abs() / self.h_sat;
1466        let l = if x > 30.0 {
1467            1.0 - 1.0 / x
1468        } else {
1469            1.0 / x.tanh() - 1.0 / x
1470        };
1471        self.lambda_sat * l * l
1472    }
1473    /// Blocked stress (zero strain) at field H \[Pa\].
1474    pub fn blocked_stress(&self, h_field: f64) -> f64 {
1475        self.elastic_modulus * self.strain_magnitude(h_field)
1476    }
1477    /// Piezomagnetic coefficient dλ/dH \[1/(A/m)\] at field H.
1478    pub fn piezomagnetic_coeff(&self, h_field: f64) -> f64 {
1479        let dh = h_field * 1e-4 + 1.0;
1480        (self.strain_magnitude(h_field + dh) - self.strain_magnitude(h_field)) / dh
1481    }
1482}
1483/// Magnetic shape memory effect in Heusler alloys (Ni-Mn-Ga).
1484///
1485/// Models variant reorientation driven by magnetic field via energy minimization.
1486/// Strain output up to ~12 % for Ni2MnGa.
1487#[derive(Debug, Clone, Copy)]
1488pub struct MagneticShape {
1489    /// Maximum transformation strain ε_max.
1490    pub max_strain: f64,
1491    /// Magnetic anisotropy energy density K_u \[J/m³\].
1492    pub k_u: f64,
1493    /// Saturation magnetization M_s \[A/m\].
1494    pub m_sat: f64,
1495    /// Critical field for reorientation H_cr \[A/m\].
1496    pub h_critical: f64,
1497    /// Current variant fraction η ∈ \[0, 1\].
1498    pub eta: f64,
1499    /// Elastic modulus \[Pa\].
1500    pub elastic_modulus: f64,
1501}
1502impl MagneticShape {
1503    /// Create a magnetic shape memory material.
1504    pub fn new(
1505        max_strain: f64,
1506        k_u: f64,
1507        m_sat: f64,
1508        h_critical: f64,
1509        elastic_modulus: f64,
1510    ) -> Self {
1511        Self {
1512            max_strain,
1513            k_u,
1514            m_sat,
1515            h_critical,
1516            eta: 0.0,
1517            elastic_modulus,
1518        }
1519    }
1520    /// Standard Ni2MnGa model.
1521    pub fn ni2mnga() -> Self {
1522        Self::new(0.06, 1.65e5, 600e3, 300e3, 2.0e9)
1523    }
1524    /// Zeeman energy density at field H (field along easy axis) \[J/m³\].
1525    pub fn zeeman_energy(&self, h_field: f64, eta: f64) -> f64 {
1526        const MU0: f64 = 4.0 * PI * 1e-7;
1527        -MU0 * self.m_sat * h_field * eta
1528    }
1529    /// Anisotropy energy density \[J/m³\].
1530    pub fn anisotropy_energy(&self, eta: f64) -> f64 {
1531        self.k_u * eta * (1.0 - eta)
1532    }
1533    /// Update variant fraction η from applied field H \[A/m\].
1534    pub fn update(&mut self, h_field: f64) {
1535        if h_field.abs() > self.h_critical {
1536            self.eta = if h_field > 0.0 { 1.0 } else { 0.0 };
1537        } else {
1538            let fraction = (h_field / self.h_critical).clamp(-1.0, 1.0);
1539            self.eta = (0.5 + 0.5 * fraction).clamp(0.0, 1.0);
1540        }
1541    }
1542    /// Macroscopic strain at current η.
1543    pub fn strain(&self) -> f64 {
1544        self.eta * self.max_strain
1545    }
1546    /// Blocking stress (stress required to prevent strain) \[Pa\].
1547    pub fn blocking_stress(&self) -> f64 {
1548        self.elastic_modulus * self.strain()
1549    }
1550}
1551/// Electrostrictive material model.
1552///
1553/// Electrostriction is a quadratic electromechanical coupling:
1554/// ε_ij = Q_ijkl P_k P_l
1555///
1556/// where ε is strain, Q is the electrostriction coefficient tensor,
1557/// and P is electric polarization.
1558///
1559/// Implements:
1560/// - Maxwell stress tensor
1561/// - P-E relationship (nonlinear dielectric)
1562/// - Strain from polarization via Q coefficient
1563#[derive(Debug, Clone, Copy)]
1564#[allow(dead_code)]
1565pub struct Electrostrictive {
1566    /// Relative permittivity ε_r (linear regime).
1567    pub epsilon_r: f64,
1568    /// Electrostriction coefficient Q_33 \[m⁴/C²\].
1569    pub q33: f64,
1570    /// Electrostriction coefficient Q_31 \[m⁴/C²\].
1571    pub q31: f64,
1572    /// Nonlinear permittivity saturation coefficient \[V²/m²\].
1573    pub sat_field: f64,
1574    /// Young's modulus \[Pa\].
1575    pub elastic_modulus: f64,
1576    /// Material thickness \[m\].
1577    pub thickness: f64,
1578}
1579impl Electrostrictive {
1580    /// Create an electrostrictive material model.
1581    pub fn new(
1582        epsilon_r: f64,
1583        q33: f64,
1584        q31: f64,
1585        sat_field: f64,
1586        elastic_modulus: f64,
1587        thickness: f64,
1588    ) -> Self {
1589        Self {
1590            epsilon_r,
1591            q33,
1592            q31,
1593            sat_field,
1594            elastic_modulus,
1595            thickness,
1596        }
1597    }
1598    /// Standard PMN-PT (lead magnesium niobate - lead titanate) model.
1599    pub fn pmn_pt() -> Self {
1600        Self::new(5000.0, 0.026, -0.010, 2e7, 6e9, 1e-3)
1601    }
1602    /// Electric polarization P at applied field E \[C/m²\].
1603    ///
1604    /// Uses nonlinear dielectric: P = ε₀ (ε_r − 1) E / (1 + |E|/E_sat)
1605    pub fn polarization(&self, e_field: f64) -> f64 {
1606        const EPS0: f64 = 8.854e-12;
1607        let chi = self.epsilon_r - 1.0;
1608        let denom = 1.0 + e_field.abs() / self.sat_field;
1609        EPS0 * chi * e_field / denom
1610    }
1611    /// Maxwell stress tensor component T_33 \[Pa\].
1612    ///
1613    /// T_33 = ε₀ ε_r E² / 2
1614    pub fn maxwell_stress(&self, e_field: f64) -> f64 {
1615        const EPS0: f64 = 8.854e-12;
1616        0.5 * EPS0 * self.epsilon_r * e_field * e_field
1617    }
1618    /// Electrostrictive strain ε_33 from polarization P.
1619    ///
1620    /// ε_33 = Q_33 · P²
1621    pub fn strain_33(&self, e_field: f64) -> f64 {
1622        let p = self.polarization(e_field);
1623        self.q33 * p * p
1624    }
1625    /// Transverse electrostrictive strain ε_31.
1626    pub fn strain_31(&self, e_field: f64) -> f64 {
1627        let p = self.polarization(e_field);
1628        self.q31 * p * p
1629    }
1630    /// Actuation displacement Δt \[m\] (thickness change).
1631    pub fn thickness_change(&self, voltage: f64) -> f64 {
1632        let e_field = voltage / self.thickness;
1633        self.strain_33(e_field) * self.thickness
1634    }
1635    /// Blocking stress \[Pa\] for zero-displacement condition.
1636    pub fn blocking_stress(&self, voltage: f64) -> f64 {
1637        let e_field = voltage / self.thickness;
1638        self.elastic_modulus * self.strain_33(e_field)
1639    }
1640    /// Electromechanical coupling coefficient k_33.
1641    ///
1642    /// k²_33 = d²_33 / (s_33 ε_33) where d_33 = 2 Q_33 P ε_0 ε_r
1643    pub fn coupling_coefficient(&self, e_field: f64) -> f64 {
1644        const EPS0: f64 = 8.854e-12;
1645        let p = self.polarization(e_field);
1646        let d33 = 2.0 * self.q33 * p * EPS0 * self.epsilon_r;
1647        let s33 = 1.0 / self.elastic_modulus;
1648        let eps33 = EPS0 * self.epsilon_r;
1649        let k2 = (d33 * d33) / (s33 * eps33);
1650        k2.sqrt().clamp(0.0, 1.0)
1651    }
1652}
1653/// Piezoelectric stack actuator model.
1654#[derive(Debug, Clone, Copy)]
1655pub struct PiezoActuator {
1656    /// Free stroke per unit voltage \[m/V\].
1657    pub stroke_per_volt: f64,
1658    /// Blocking force per unit voltage \[N/V\].
1659    pub force_per_volt: f64,
1660    /// Stiffness \[N/m\].
1661    pub stiffness: f64,
1662    /// Resonance frequency \[Hz\].
1663    pub resonance_freq: f64,
1664    /// Capacitance \[F\].
1665    pub capacitance: f64,
1666    /// Hysteresis coefficient (0 = linear, >0 = hysteresis).
1667    pub hysteresis: f64,
1668}
1669impl PiezoActuator {
1670    /// Create a piezoelectric stack actuator.
1671    pub fn new(
1672        stroke_per_volt: f64,
1673        force_per_volt: f64,
1674        stiffness: f64,
1675        resonance_freq: f64,
1676        capacitance: f64,
1677    ) -> Self {
1678        Self {
1679            stroke_per_volt,
1680            force_per_volt,
1681            stiffness,
1682            resonance_freq,
1683            capacitance,
1684            hysteresis: 0.1,
1685        }
1686    }
1687    /// Free displacement at voltage V \[m\].
1688    pub fn free_stroke(&self, voltage: f64) -> f64 {
1689        self.stroke_per_volt * voltage
1690    }
1691    /// Blocking force at voltage V \[N\].
1692    pub fn blocking_force(&self, voltage: f64) -> f64 {
1693        self.force_per_volt * voltage
1694    }
1695    /// Output force at voltage V with external load displacement δ \[N\].
1696    pub fn output_force(&self, voltage: f64, displacement: f64) -> f64 {
1697        let free = self.free_stroke(voltage);
1698        self.stiffness * (free - displacement)
1699    }
1700    /// Electrical energy input per cycle \[J\].
1701    pub fn energy_per_cycle(&self, voltage: f64, freq: f64) -> f64 {
1702        let _ = freq;
1703        0.5 * self.capacitance * voltage * voltage
1704    }
1705    /// Mechanical work output \[J\].
1706    pub fn mechanical_work(&self, voltage: f64) -> f64 {
1707        let f = self.blocking_force(voltage);
1708        let d = self.free_stroke(voltage);
1709        0.5 * f * d
1710    }
1711}
1712/// Thermochromic material: color/reflectivity changes with temperature.
1713///
1714/// Models an RGB reflectance spectrum shift through a transition temperature
1715/// using a smooth sigmoid function.
1716#[derive(Debug, Clone)]
1717pub struct ThermochromicResponse {
1718    /// Transition temperature \[K\].
1719    pub t_transition: f64,
1720    /// Transition width (steepness) \[K\].
1721    pub delta_t: f64,
1722    /// Low-temperature RGB reflectance (0..1 each channel).
1723    pub color_low: [f64; 3],
1724    /// High-temperature RGB reflectance (0..1 each channel).
1725    pub color_high: [f64; 3],
1726    /// Latent heat of transition \[J/kg\].
1727    pub latent_heat: f64,
1728}
1729impl ThermochromicResponse {
1730    /// Create a thermochromic material.
1731    pub fn new(
1732        t_transition: f64,
1733        delta_t: f64,
1734        color_low: [f64; 3],
1735        color_high: [f64; 3],
1736        latent_heat: f64,
1737    ) -> Self {
1738        Self {
1739            t_transition,
1740            delta_t,
1741            color_low,
1742            color_high,
1743            latent_heat,
1744        }
1745    }
1746    /// Transition fraction s ∈ \[0, 1\] at temperature T.
1747    ///
1748    /// s = 0 → low-T color, s = 1 → high-T color.
1749    pub fn transition_fraction(&self, temperature: f64) -> f64 {
1750        let x = (temperature - self.t_transition) / self.delta_t;
1751        1.0 / (1.0 + (-x).exp())
1752    }
1753    /// RGB reflectance \[r, g, b\] at temperature T.
1754    pub fn reflectance(&self, temperature: f64) -> [f64; 3] {
1755        let s = self.transition_fraction(temperature);
1756        [
1757            self.color_low[0] + s * (self.color_high[0] - self.color_low[0]),
1758            self.color_low[1] + s * (self.color_high[1] - self.color_low[1]),
1759            self.color_low[2] + s * (self.color_high[2] - self.color_low[2]),
1760        ]
1761    }
1762    /// Grayscale luminance at temperature T.
1763    pub fn luminance(&self, temperature: f64) -> f64 {
1764        let [r, g, b] = self.reflectance(temperature);
1765        0.2126 * r + 0.7152 * g + 0.0722 * b
1766    }
1767    /// Specific heat capacity including latent heat peak \[J/(kg·K)\].
1768    ///
1769    /// c_eff(T) = c_base + L * ds/dT
1770    pub fn effective_heat_capacity(&self, temperature: f64, c_base: f64) -> f64 {
1771        let s = self.transition_fraction(temperature);
1772        let ds_dt = s * (1.0 - s) / self.delta_t;
1773        c_base + self.latent_heat * ds_dt
1774    }
1775}
1776/// Smart piezoelectric actuator/sensor model.
1777///
1778/// Implements the full piezoelectric constitutive equations:
1779/// - Strain from electric field (converse effect): S = d · E
1780/// - Charge from stress (direct effect): D = d · T
1781/// - Resonance frequency from elastic compliance and geometry
1782/// - Actuation stroke and coupling coefficient k
1783#[derive(Debug, Clone, Copy)]
1784#[allow(dead_code)]
1785pub struct PiezoelectricSmart {
1786    /// d_33 piezoelectric charge coefficient \[C/N = m/V\].
1787    pub d33: f64,
1788    /// d_31 piezoelectric charge coefficient \[C/N = m/V\].
1789    pub d31: f64,
1790    /// d_15 (shear) piezoelectric charge coefficient \[C/N = m/V\].
1791    pub d15: f64,
1792    /// Elastic compliance s_33 at constant E field \[m²/N\].
1793    pub s33_e: f64,
1794    /// Elastic compliance s_11 at constant E field \[m²/N\].
1795    pub s11_e: f64,
1796    /// Permittivity ε_33 at constant stress \[F/m\].
1797    pub eps33_t: f64,
1798    /// Material density \[kg/m³\].
1799    pub density: f64,
1800    /// Geometry: length along poling axis \[m\].
1801    pub length: f64,
1802    /// Cross-sectional area \[m²\].
1803    pub area: f64,
1804}
1805impl PiezoelectricSmart {
1806    /// Create a piezoelectric smart material model.
1807    #[allow(clippy::too_many_arguments)]
1808    pub fn new(
1809        d33: f64,
1810        d31: f64,
1811        d15: f64,
1812        s33_e: f64,
1813        s11_e: f64,
1814        eps33_t: f64,
1815        density: f64,
1816        length: f64,
1817        area: f64,
1818    ) -> Self {
1819        Self {
1820            d33,
1821            d31,
1822            d15,
1823            s33_e,
1824            s11_e,
1825            eps33_t,
1826            density,
1827            length,
1828            area,
1829        }
1830    }
1831    /// Standard PZT-5H model.
1832    pub fn pzt5h() -> Self {
1833        const EPS0: f64 = 8.854e-12;
1834        Self::new(
1835            593e-12,
1836            -274e-12,
1837            741e-12,
1838            20.7e-12,
1839            16.5e-12,
1840            3400.0 * EPS0,
1841            7500.0,
1842            10e-3,
1843            1e-4,
1844        )
1845    }
1846    /// Standard PZT-4 model.
1847    pub fn pzt4() -> Self {
1848        const EPS0: f64 = 8.854e-12;
1849        Self::new(
1850            289e-12,
1851            -123e-12,
1852            496e-12,
1853            15.5e-12,
1854            12.3e-12,
1855            1300.0 * EPS0,
1856            7600.0,
1857            10e-3,
1858            1e-4,
1859        )
1860    }
1861    /// Electromechanical coupling coefficient k_33.
1862    ///
1863    /// k²_33 = d²_33 / (s³³^E · ε³³^T)
1864    pub fn k33(&self) -> f64 {
1865        let k2 = self.d33 * self.d33 / (self.s33_e * self.eps33_t);
1866        k2.sqrt().clamp(0.0, 1.0)
1867    }
1868    /// Planar coupling coefficient k_p (thin disc resonator).
1869    pub fn kp(&self) -> f64 {
1870        let k31_2 = self.d31 * self.d31 / (self.s11_e * self.eps33_t);
1871        let kp2 = 2.0 * k31_2 / 0.7_f64;
1872        kp2.sqrt().clamp(0.0, 1.0)
1873    }
1874    /// Resonance frequency of longitudinal mode \[Hz\].
1875    ///
1876    /// f_r = 1 / (2·L) · √(1 / (ρ · s³³^E))
1877    pub fn resonance_frequency(&self) -> f64 {
1878        let v_sound = (1.0 / (self.density * self.s33_e)).sqrt();
1879        v_sound / (2.0 * self.length)
1880    }
1881    /// Anti-resonance frequency f_a from resonance via k33.
1882    pub fn antiresonance_frequency(&self) -> f64 {
1883        let fr = self.resonance_frequency();
1884        let k = self.k33();
1885        fr * (1.0 / (1.0 - k * k)).sqrt()
1886    }
1887    /// Free stroke (actuation displacement) Δl \[m\] for voltage V.
1888    ///
1889    /// Δl = d_33 · V
1890    pub fn actuation_stroke(&self, voltage: f64) -> f64 {
1891        self.d33 * voltage
1892    }
1893    /// Generalized actuation stroke in 31 mode (transverse) \[m\].
1894    pub fn actuation_stroke_31(&self, voltage: f64) -> f64 {
1895        let thickness = self.length;
1896        let width = self.area.sqrt();
1897        self.d31 * voltage / thickness * width
1898    }
1899    /// Blocking force \[N\] for 33-mode actuator.
1900    ///
1901    /// F_block = d_33 · E · A / s³³^E = d_33 · V · A / (s³³^E · L)
1902    pub fn blocking_force(&self, voltage: f64) -> f64 {
1903        self.d33 * voltage * self.area / (self.s33_e * self.length)
1904    }
1905    /// Charge generated by applied stress (direct piezoelectric effect) \[C\].
1906    ///
1907    /// Q = d_33 · F (force along poling axis)
1908    pub fn charge_from_force(&self, force: f64) -> f64 {
1909        self.d33 * force
1910    }
1911    /// Open-circuit voltage from applied force \[V\].
1912    pub fn voltage_from_force(&self, force: f64) -> f64 {
1913        let stress = force / self.area;
1914        self.d33 * stress * self.length / self.eps33_t
1915    }
1916    /// Mechanical quality factor Q_m estimate from bandwidth.
1917    ///
1918    /// Q_m = f_r / (f_a - f_r) (approximate).
1919    pub fn mechanical_q(&self) -> f64 {
1920        let fr = self.resonance_frequency();
1921        let fa = self.antiresonance_frequency();
1922        if (fa - fr).abs() < 1e-3 {
1923            1000.0
1924        } else {
1925            fr / (fa - fr)
1926        }
1927    }
1928}
1929/// SMA wire actuator using Brinson constitutive model.
1930///
1931/// Models recovery stress and stroke output during a temperature cycle.
1932/// The Brinson model separates martensite into stress-induced (ξs)
1933/// and temperature-induced (ξT) fractions.
1934#[derive(Debug, Clone)]
1935pub struct SmaActuator {
1936    /// Wire cross-sectional area \[m²\].
1937    pub area: f64,
1938    /// Wire rest length \[m\].
1939    pub length: f64,
1940    /// Stress-induced martensite fraction ξ_s ∈ \[0, 1\].
1941    pub xi_s: f64,
1942    /// Temperature-induced martensite fraction ξ_T ∈ \[0, 1\].
1943    pub xi_t: f64,
1944    /// Underlying SMA constitutive model.
1945    pub sma: ShapeMemoryAlloy,
1946    /// Current applied strain.
1947    pub strain: f64,
1948    /// Current stress \[Pa\].
1949    pub stress: f64,
1950}
1951impl SmaActuator {
1952    /// Create an SMA wire actuator from SMA properties and geometry.
1953    pub fn new(sma: ShapeMemoryAlloy, area: f64, length: f64) -> Self {
1954        Self {
1955            area,
1956            length,
1957            xi_s: 0.0,
1958            xi_t: sma.xi,
1959            sma,
1960            strain: 0.0,
1961            stress: 0.0,
1962        }
1963    }
1964    /// Total martensite fraction ξ = ξ_s + ξ_T.
1965    pub fn total_xi(&self) -> f64 {
1966        (self.xi_s + self.xi_t).clamp(0.0, 1.0)
1967    }
1968    /// Recovery stress for current strain and temperature \[Pa\].
1969    ///
1970    /// Uses Brinson: σ = E(ξ) * (ε - ε_L * ξ_s) where ε_L = max_strain.
1971    pub fn recovery_stress(&self) -> f64 {
1972        let e = self.sma.e_a + self.total_xi() * (self.sma.e_m - self.sma.e_a);
1973        e * (self.strain - self.sma.max_strain * self.xi_s)
1974    }
1975    /// Stroke (shortening) of the actuator wire \[m\].
1976    ///
1977    /// Full stroke = max_strain * length when going from full martensite to austenite.
1978    pub fn stroke(&self, xi_final: f64) -> f64 {
1979        let delta_xi = self.total_xi() - xi_final.clamp(0.0, 1.0);
1980        delta_xi * self.sma.max_strain * self.length
1981    }
1982    /// Force output of the actuator \[N\].
1983    pub fn force(&self) -> f64 {
1984        self.recovery_stress() * self.area
1985    }
1986    /// Update actuator for given temperature \[K\] and external strain.
1987    pub fn update(&mut self, temperature: f64, applied_strain: f64) {
1988        self.strain = applied_strain;
1989        let xi_new = self.sma.update_phase(temperature, self.stress);
1990        let xi_s_new = (applied_strain / self.sma.max_strain.max(1e-12)).clamp(0.0, xi_new);
1991        self.xi_s = xi_s_new;
1992        self.xi_t = (xi_new - xi_s_new).max(0.0);
1993        self.stress = self.recovery_stress();
1994    }
1995}
1996/// Piezoelectric material coupling: voltage → strain (converse effect)
1997/// and stress → charge (direct effect).
1998///
1999/// Uses the d33 (longitudinal) and d31 (transverse) piezo coefficients.
2000#[derive(Debug, Clone, Copy)]
2001pub struct PiezoelectricCoupling {
2002    /// d33 piezoelectric coefficient \[C/N = m/V\].
2003    pub d33: f64,
2004    /// d31 piezoelectric coefficient \[C/N = m/V\] (typically negative).
2005    pub d31: f64,
2006    /// Elastic modulus along poling axis \[Pa\].
2007    pub elastic_modulus_33: f64,
2008    /// Elastic modulus transverse \[Pa\].
2009    pub elastic_modulus_11: f64,
2010    /// Relative permittivity ε_r along poling axis (free).
2011    pub epsilon_r33: f64,
2012    /// Coupling factor k33 (electromechanical).
2013    pub k33: f64,
2014}
2015impl PiezoelectricCoupling {
2016    /// Create a piezoelectric coupling model.
2017    pub fn new(
2018        d33: f64,
2019        d31: f64,
2020        elastic_modulus_33: f64,
2021        elastic_modulus_11: f64,
2022        epsilon_r33: f64,
2023    ) -> Self {
2024        const EPS0: f64 = 8.854e-12;
2025        let k33 = d33 * (elastic_modulus_33 / (EPS0 * epsilon_r33)).sqrt();
2026        Self {
2027            d33,
2028            d31,
2029            elastic_modulus_33,
2030            elastic_modulus_11,
2031            epsilon_r33,
2032            k33,
2033        }
2034    }
2035    /// Standard PZT-5A model.
2036    pub fn pzt5a() -> Self {
2037        Self::new(374e-12, -171e-12, 61e9, 61e9, 1700.0)
2038    }
2039    /// Strain along poling axis (33 direction) from applied voltage V over
2040    /// thickness t \[m\].  ε33 = d33 * E_field = d33 * V / t.
2041    pub fn strain_from_voltage_33(&self, voltage: f64, thickness: f64) -> f64 {
2042        self.d33 * voltage / thickness
2043    }
2044    /// Transverse strain (31 direction) from applied voltage.
2045    pub fn strain_from_voltage_31(&self, voltage: f64, thickness: f64) -> f64 {
2046        self.d31 * voltage / thickness
2047    }
2048    /// Charge density (polarization) from applied stress σ33 \[C/m²\].
2049    pub fn charge_from_stress_33(&self, stress: f64) -> f64 {
2050        self.d33 * stress
2051    }
2052    /// Open-circuit voltage developed from applied stress σ33 \[V\].
2053    pub fn voltage_from_stress(&self, stress: f64, thickness: f64) -> f64 {
2054        const EPS0: f64 = 8.854e-12;
2055        let p = self.charge_from_stress_33(stress);
2056        p * thickness / (EPS0 * self.epsilon_r33)
2057    }
2058    /// Mechanical energy harvested from stress cycle \[J/m³\].
2059    pub fn harvested_energy_density(&self, stress_amplitude: f64) -> f64 {
2060        0.5 * self.d33 * self.d33 * stress_amplitude * stress_amplitude
2061            / (self.epsilon_r33 * 8.854e-12)
2062    }
2063}