Skip to content

Commit dda0294

Browse files
committed
added saturating trend model curve
1 parent 6c56eec commit dda0294

36 files changed

Lines changed: 1655 additions & 96 deletions

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,13 @@ use self::types::dialog_directory_from_path;
9898
use self::types::{
9999
ExtendedMetrics, FormulaReferenceSection, FormulaSvgCache, ModelFormulaInfo, ParamInitMethod,
100100
PlotTool, SampledCurveCache, SprayBrush, UiLanguage, params_to_input_strings,
101+
tau_grid_to_input_strings,
101102
};
102103
use crate::domain::{
103-
AdamConfig, CurveFamily, CurveParams, FitResult, LbfgsConfig, MAX_RATIONAL_DEGREE,
104-
MIN_RATIONAL_DEGREE, NelderMeadConfig, NewtonCgConfig, OptimizerConfig, OptimizerMethod, Point,
105-
Points, SgdConfig, SteepestDescentConfig,
104+
AdamConfig, CurveFamily, CurveParams, DEFAULT_SATURATING_TREND_TAUS_YEARS, FitResult,
105+
LbfgsConfig, MAX_RATIONAL_DEGREE, MAX_SATURATING_TREND_TAU_COUNT, MIN_RATIONAL_DEGREE,
106+
MIN_SATURATING_TREND_TAU_COUNT, NelderMeadConfig, NewtonCgConfig, OptimizerConfig,
107+
OptimizerMethod, Point, Points, SaturatingTrendTauGrid, SgdConfig, SteepestDescentConfig,
106108
};
107109
#[cfg(not(target_arch = "wasm32"))]
108110
use crate::fit::FitError;

src/app/bootstrap.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ impl Default for CurveFitApp {
1818
let selected_model = ModelChoice::Polynomial;
1919
let polynomial_degree = 1;
2020
let rational_degree = MIN_RATIONAL_DEGREE;
21+
let saturating_trend_tau_count = MAX_SATURATING_TREND_TAU_COUNT;
2122
let selected_family = polynomial_family(polynomial_degree);
2223
let default_lbfgs = LbfgsConfig::default();
2324
let default_nelder_mead = NelderMeadConfig::default();
@@ -52,6 +53,10 @@ impl Default for CurveFitApp {
5253
selected_model,
5354
polynomial_degree,
5455
rational_degree,
56+
saturating_trend_tau_count,
57+
saturating_trend_tau_inputs: tau_grid_to_input_strings(
58+
&DEFAULT_SATURATING_TREND_TAUS_YEARS,
59+
),
5560
parameter_inputs: params_to_input_strings(&selected_family.default_params()),
5661
optimizer_method: OptimizerMethod::Lbfgs,
5762
optimizer_mode: OptimizerUiMode::Basic,

src/app/fit_worker.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -893,7 +893,7 @@ impl CurveFitApp {
893893
active_fit_points,
894894
optimization_initial_params,
895895
normalization,
896-
) = if self.normalize_parametric_data {
896+
) = if self.normalize_parametric_data && family.supports_parametric_normalization() {
897897
let normalization = match ParametricNormalization::try_from_points(&points) {
898898
Ok(normalization) => normalization,
899899
Err(error) => {

src/app/formula.rs

Lines changed: 162 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ use ratex_types::display_item::DisplayList;
1010

1111
use super::i18n::tr;
1212
use super::{FormulaReferenceSection, ModelChoice, ModelFormulaInfo, ResolvedModel, UiLanguage};
13-
use crate::domain::{MAX_RATIONAL_DEGREE, MIN_RATIONAL_DEGREE};
13+
use crate::domain::DEFAULT_SATURATING_TREND_TAUS_YEARS;
14+
use crate::domain::{
15+
MAX_RATIONAL_DEGREE, MAX_SATURATING_TREND_TAU_COUNT, MIN_RATIONAL_DEGREE,
16+
MIN_SATURATING_TREND_TAU_COUNT,
17+
};
1418
use crate::fit::OptimizationLossMetric;
1519

1620
// Держим размер формулы на уровне основного текста интерфейса.
@@ -75,6 +79,8 @@ fn model_formula_source(
7579
model: ModelChoice,
7680
polynomial_degree: usize,
7781
rational_degree: usize,
82+
saturating_trend_tau_count: usize,
83+
saturating_trend_taus: Option<&[f64]>,
7884
) -> FormulaSource {
7985
match model {
8086
ModelChoice::Polynomial => {
@@ -147,6 +153,10 @@ L(x; x_0, \gamma) &= \frac{1}{1 + (\frac{x - x_0}{\gamma})^{2}}
147153
G(x; x_0, σ) = exp(-((x - x_0)^2)∕(2·σ^2))\n\
148154
L(x; x_0, γ) = 1∕(1 + ((x - x_0)∕γ)^2)",
149155
),
156+
ModelChoice::SaturatingTrendBasis => FormulaSource::explicit(
157+
&saturating_trend_formula_latex(saturating_trend_tau_count, saturating_trend_taus),
158+
&saturating_trend_formula_plain_text(saturating_trend_tau_count, saturating_trend_taus),
159+
),
150160
ModelChoice::LinearSpline => FormulaSource::single(
151161
r"y(x) = y_{i} + \frac{y_{i+1} - y_{i}}{x_{i+1} - x_{i}} \cdot (x - x_{i})",
152162
),
@@ -170,9 +180,17 @@ pub(super) fn model_formula_info(
170180
model: ModelChoice,
171181
polynomial_degree: usize,
172182
rational_degree: usize,
183+
saturating_trend_tau_count: usize,
184+
saturating_trend_taus: Option<&[f64]>,
173185
optimization_metric: OptimizationLossMetric,
174186
) -> ModelFormulaInfo {
175-
let model_formula = model_formula_source(model, polynomial_degree, rational_degree);
187+
let model_formula = model_formula_source(
188+
model,
189+
polynomial_degree,
190+
rational_degree,
191+
saturating_trend_tau_count,
192+
saturating_trend_taus,
193+
);
176194
let model_section = FormulaReferenceSection {
177195
title: tr(language, "Model equation", "Уравнение модели").to_string(),
178196
render_latex: model_formula.render_latex.clone(),
@@ -182,6 +200,7 @@ pub(super) fn model_formula_info(
182200
model,
183201
polynomial_degree,
184202
rational_degree,
203+
saturating_trend_tau_count,
185204
),
186205
};
187206

@@ -253,8 +272,14 @@ fn model_reference_description(
253272
model: ModelChoice,
254273
polynomial_degree: usize,
255274
rational_degree: usize,
275+
saturating_trend_tau_count: usize,
256276
) -> String {
257-
let min_points = model_min_points(model, polynomial_degree, rational_degree);
277+
let min_points = model_min_points(
278+
model,
279+
polynomial_degree,
280+
rational_degree,
281+
saturating_trend_tau_count,
282+
);
258283
let mut description = format!(
259284
"{}: {min_points}\n{}: x - {}, y - {}",
260285
tr(language, "Minimum points", "Минимум точек"),
@@ -366,15 +391,69 @@ fn reference_plain_text(sections: &[FormulaReferenceSection]) -> String {
366391
output
367392
}
368393

369-
fn model_min_points(model: ModelChoice, polynomial_degree: usize, rational_degree: usize) -> usize {
370-
match ResolvedModel::from_choice(model, polynomial_degree, rational_degree) {
394+
fn model_min_points(
395+
model: ModelChoice,
396+
polynomial_degree: usize,
397+
rational_degree: usize,
398+
saturating_trend_tau_count: usize,
399+
) -> usize {
400+
match ResolvedModel::from_choice(
401+
model,
402+
polynomial_degree,
403+
rational_degree,
404+
saturating_trend_tau_count,
405+
) {
371406
ResolvedModel::Parametric(family) => family.min_points(),
372407
ResolvedModel::LinearSpline | ResolvedModel::MonotoneCubicSpline => 2,
373408
ResolvedModel::NaturalCubicSpline => 3,
374409
ResolvedModel::AkimaSpline => 5,
375410
}
376411
}
377412

413+
fn saturating_trend_tau_values(tau_count: usize) -> &'static [f64] {
414+
let clamped = tau_count.clamp(
415+
MIN_SATURATING_TREND_TAU_COUNT,
416+
MAX_SATURATING_TREND_TAU_COUNT,
417+
);
418+
&DEFAULT_SATURATING_TREND_TAUS_YEARS[..clamped]
419+
}
420+
421+
fn saturating_trend_formula_latex(tau_count: usize, custom_taus: Option<&[f64]>) -> String {
422+
let taus = custom_taus.unwrap_or_else(|| saturating_trend_tau_values(tau_count));
423+
let taus = taus
424+
.iter()
425+
.map(|tau| trim_float_for_formula(*tau))
426+
.collect::<Vec<_>>()
427+
.join(", ");
428+
let count = custom_taus
429+
.unwrap_or_else(|| saturating_trend_tau_values(tau_count))
430+
.len();
431+
format!(
432+
r"y = c + \sum_{{i=1}}^{{{count}}} w_{{i}} \cdot (1 - \exp(-\frac{{x}}{{\tau_i}})), \quad \tau_i \in \{{{taus}\}}"
433+
)
434+
}
435+
436+
fn saturating_trend_formula_plain_text(tau_count: usize, custom_taus: Option<&[f64]>) -> String {
437+
let taus = custom_taus.unwrap_or_else(|| saturating_trend_tau_values(tau_count));
438+
let taus = taus
439+
.iter()
440+
.map(|tau| trim_float_for_formula(*tau))
441+
.collect::<Vec<_>>()
442+
.join(", ");
443+
let count = custom_taus
444+
.unwrap_or_else(|| saturating_trend_tau_values(tau_count))
445+
.len();
446+
format!("y = c + sum_{{i=1..{count}}}(w_i·(1 - exp(-x/τ_i))), τ_i in {{{taus}}}")
447+
}
448+
449+
fn trim_float_for_formula(value: f64) -> String {
450+
let formatted = format!("{value:.2}");
451+
formatted
452+
.trim_end_matches('0')
453+
.trim_end_matches('.')
454+
.to_string()
455+
}
456+
378457
fn model_constraint_note(language: UiLanguage, model: ModelChoice) -> Option<&'static str> {
379458
match model {
380459
ModelChoice::Arrhenius
@@ -429,6 +508,9 @@ fn model_ml_note(language: UiLanguage, model: ModelChoice) -> &'static str {
429508
(UiLanguage::English, ModelChoice::PseudoVoigt) => {
430509
"Mixture of Gaussian and Lorentzian peaks with learnable blend."
431510
}
511+
(UiLanguage::English, ModelChoice::SaturatingTrendBasis) => {
512+
"Fixed saturating basis over a preset tau grid; x is interpreted in years."
513+
}
432514
(UiLanguage::English, ModelChoice::HyperbolicTangent) => {
433515
"Smooth S-curve transition with bounded tails."
434516
}
@@ -490,6 +572,9 @@ fn model_ml_note(language: UiLanguage, model: ModelChoice) -> &'static str {
490572
(UiLanguage::Russian, ModelChoice::PseudoVoigt) => {
491573
"Смесь гауссового и лоренцевого пиков с обучаемой долей."
492574
}
575+
(UiLanguage::Russian, ModelChoice::SaturatingTrendBasis) => {
576+
"Фиксированный базис насыщения по сетке tau; x интерпретируется как годы."
577+
}
493578
(UiLanguage::Russian, ModelChoice::HyperbolicTangent) => {
494579
"Гладкий S-переход с ограниченными хвостами."
495580
}
@@ -856,18 +941,34 @@ mod tests {
856941
#[test]
857942
fn every_model_formula_renders_as_svg() {
858943
for model in ModelChoice::ALL {
859-
let degree_pairs = if model.is_polynomial() {
860-
[(1_usize, 1_usize), (9, 1)].as_slice()
944+
let degree_triplets = if model.is_polynomial() {
945+
[
946+
(1_usize, 1_usize, MAX_SATURATING_TREND_TAU_COUNT),
947+
(9, 1, MAX_SATURATING_TREND_TAU_COUNT),
948+
]
949+
.as_slice()
861950
} else if model.is_rational() {
862-
[(1_usize, 1_usize), (1, 5)].as_slice()
951+
[
952+
(1_usize, 1_usize, MAX_SATURATING_TREND_TAU_COUNT),
953+
(1, 5, MAX_SATURATING_TREND_TAU_COUNT),
954+
]
955+
.as_slice()
956+
} else if model.is_saturating_trend_basis() {
957+
[
958+
(1_usize, 1_usize, MIN_SATURATING_TREND_TAU_COUNT),
959+
(1, 1, MAX_SATURATING_TREND_TAU_COUNT),
960+
]
961+
.as_slice()
863962
} else {
864-
[(1_usize, 1_usize)].as_slice()
963+
[(1_usize, 1_usize, MAX_SATURATING_TREND_TAU_COUNT)].as_slice()
865964
};
866-
for &(polynomial_degree, rational_degree) in degree_pairs {
965+
for &(polynomial_degree, rational_degree, tau_count) in degree_triplets {
867966
let degree = if model.is_polynomial() {
868967
polynomial_degree
869968
} else if model.is_rational() {
870969
rational_degree
970+
} else if model.is_saturating_trend_basis() {
971+
tau_count
871972
} else {
872973
1
873974
};
@@ -876,6 +977,8 @@ mod tests {
876977
model,
877978
polynomial_degree,
878979
rational_degree,
980+
tau_count,
981+
None,
879982
OptimizationLossMetric::Mse,
880983
);
881984
for section in &formula.sections {
@@ -907,18 +1010,34 @@ mod tests {
9071010
#[test]
9081011
fn every_model_plain_text_stays_readable() {
9091012
for model in ModelChoice::ALL {
910-
let degree_pairs = if model.is_polynomial() {
911-
[(1_usize, 1_usize), (9, 1)].as_slice()
1013+
let degree_triplets = if model.is_polynomial() {
1014+
[
1015+
(1_usize, 1_usize, MAX_SATURATING_TREND_TAU_COUNT),
1016+
(9, 1, MAX_SATURATING_TREND_TAU_COUNT),
1017+
]
1018+
.as_slice()
9121019
} else if model.is_rational() {
913-
[(1_usize, 1_usize), (1, 5)].as_slice()
1020+
[
1021+
(1_usize, 1_usize, MAX_SATURATING_TREND_TAU_COUNT),
1022+
(1, 5, MAX_SATURATING_TREND_TAU_COUNT),
1023+
]
1024+
.as_slice()
1025+
} else if model.is_saturating_trend_basis() {
1026+
[
1027+
(1_usize, 1_usize, MIN_SATURATING_TREND_TAU_COUNT),
1028+
(1, 1, MAX_SATURATING_TREND_TAU_COUNT),
1029+
]
1030+
.as_slice()
9141031
} else {
915-
[(1_usize, 1_usize)].as_slice()
1032+
[(1_usize, 1_usize, MAX_SATURATING_TREND_TAU_COUNT)].as_slice()
9161033
};
917-
for &(polynomial_degree, rational_degree) in degree_pairs {
1034+
for &(polynomial_degree, rational_degree, tau_count) in degree_triplets {
9181035
let degree = if model.is_polynomial() {
9191036
polynomial_degree
9201037
} else if model.is_rational() {
9211038
rational_degree
1039+
} else if model.is_saturating_trend_basis() {
1040+
tau_count
9221041
} else {
9231042
1
9241043
};
@@ -927,6 +1046,8 @@ mod tests {
9271046
model,
9281047
polynomial_degree,
9291048
rational_degree,
1049+
tau_count,
1050+
None,
9301051
OptimizationLossMetric::Mse,
9311052
);
9321053
let plain_text = formula.model_plain_text.trim();
@@ -958,8 +1079,15 @@ mod tests {
9581079
#[test]
9591080
fn every_optimization_metric_formula_renders_as_svg() {
9601081
for metric in OptimizationLossMetric::ALL {
961-
let formula =
962-
model_formula_info(UiLanguage::English, ModelChoice::Polynomial, 3, 1, metric);
1082+
let formula = model_formula_info(
1083+
UiLanguage::English,
1084+
ModelChoice::Polynomial,
1085+
3,
1086+
1,
1087+
MAX_SATURATING_TREND_TAU_COUNT,
1088+
None,
1089+
metric,
1090+
);
9631091
let metric_section = formula
9641092
.sections
9651093
.iter()
@@ -983,6 +1111,8 @@ mod tests {
9831111
ModelChoice::Polynomial,
9841112
3,
9851113
1,
1114+
MAX_SATURATING_TREND_TAU_COUNT,
1115+
None,
9861116
OptimizationLossMetric::SoftL1,
9871117
);
9881118
let titles: Vec<&str> = formula
@@ -1013,6 +1143,8 @@ mod tests {
10131143
ModelChoice::Polynomial,
10141144
3,
10151145
1,
1146+
MAX_SATURATING_TREND_TAU_COUNT,
1147+
None,
10161148
OptimizationLossMetric::Mse,
10171149
);
10181150
let metric_section = formula
@@ -1034,8 +1166,15 @@ mod tests {
10341166
#[test]
10351167
fn optimization_metric_reference_omits_loss_derivative_formulas() {
10361168
for metric in OptimizationLossMetric::ALL {
1037-
let formula =
1038-
model_formula_info(UiLanguage::English, ModelChoice::Polynomial, 3, 1, metric);
1169+
let formula = model_formula_info(
1170+
UiLanguage::English,
1171+
ModelChoice::Polynomial,
1172+
3,
1173+
1,
1174+
MAX_SATURATING_TREND_TAU_COUNT,
1175+
None,
1176+
metric,
1177+
);
10391178
let metric_section = formula
10401179
.sections
10411180
.iter()
@@ -1076,6 +1215,8 @@ mod tests {
10761215
ModelChoice::PseudoVoigt,
10771216
1,
10781217
1,
1218+
MAX_SATURATING_TREND_TAU_COUNT,
1219+
None,
10791220
OptimizationLossMetric::Mse,
10801221
);
10811222
let model_section = formula
@@ -1096,6 +1237,8 @@ mod tests {
10961237
ModelChoice::PseudoVoigt,
10971238
1,
10981239
1,
1240+
MAX_SATURATING_TREND_TAU_COUNT,
1241+
None,
10991242
OptimizationLossMetric::Mse,
11001243
);
11011244

0 commit comments

Comments
 (0)