1use crate::ids::LoadCaseId;
2use crate::model::LoadCombination;
3
4#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct SnowFactors {
13 pub delta1: f64,
14 pub delta3: f64,
15}
16
17impl Default for SnowFactors {
18 fn default() -> Self {
19 SnowFactors {
20 delta1: 0.7,
21 delta3: 0.35,
22 }
23 }
24}
25
26pub struct ComboInput {
28 pub dl: LoadCaseId,
29 pub ll: LoadCaseId,
30 pub seismic_x: Option<LoadCaseId>,
31 pub seismic_y: Option<LoadCaseId>,
32 pub snow: Option<LoadCaseId>,
33 pub heavy_snow_zone: bool,
37 pub snow_factors: Option<SnowFactors>,
39}
40
41fn push_gp(combos: &mut Vec<LoadCombination>, dl: LoadCaseId, ll: LoadCaseId) {
42 combos.push(LoadCombination {
43 name: "DL + LL".into(),
44 terms: vec![(dl, 1.0), (ll, 1.0)],
45 });
46}
47
48pub fn standard_combinations(input: &ComboInput) -> Vec<LoadCombination> {
65 let mut combos = Vec::new();
66 let dl = input.dl;
67 let ll = input.ll;
68 let sf = input.snow_factors.unwrap_or_default();
69
70 push_gp(&mut combos, dl, ll);
72
73 if input.heavy_snow_zone {
75 if let Some(snow) = input.snow {
76 combos.push(LoadCombination {
77 name: format!("DL + LL + {}SL", trim_f64(sf.delta1)),
78 terms: vec![(dl, 1.0), (ll, 1.0), (snow, sf.delta1)],
79 });
80 }
81 }
82
83 if let Some(snow) = input.snow {
85 combos.push(LoadCombination {
86 name: "DL + LL + SL".into(),
87 terms: vec![(dl, 1.0), (ll, 1.0), (snow, 1.0)],
88 });
89 }
90
91 push_directional(
93 &mut combos,
94 dl,
95 ll,
96 input.seismic_x,
97 "EX",
98 input.snow,
99 input.heavy_snow_zone,
100 sf.delta3,
101 );
102 push_directional(
103 &mut combos,
104 dl,
105 ll,
106 input.seismic_y,
107 "EY",
108 input.snow,
109 input.heavy_snow_zone,
110 sf.delta3,
111 );
112
113 combos
114}
115
116fn trim_f64(v: f64) -> String {
118 let s = format!("{v:.3}");
119 s.trim_end_matches('0').trim_end_matches('.').to_string()
120}
121
122#[allow(clippy::too_many_arguments)]
125fn push_directional(
126 combos: &mut Vec<LoadCombination>,
127 dl: LoadCaseId,
128 ll: LoadCaseId,
129 case: Option<LoadCaseId>,
130 label: &str,
131 snow: Option<LoadCaseId>,
132 heavy_snow_zone: bool,
133 delta: f64,
134) {
135 let Some(case) = case else {
136 return;
137 };
138 combos.push(LoadCombination {
139 name: format!("DL + LL + {label}"),
140 terms: vec![(dl, 1.0), (ll, 1.0), (case, 1.0)],
141 });
142 combos.push(LoadCombination {
143 name: format!("DL + LL - {label}"),
144 terms: vec![(dl, 1.0), (ll, 1.0), (case, -1.0)],
145 });
146 if heavy_snow_zone {
147 if let Some(snow) = snow {
148 let d = trim_f64(delta);
149 combos.push(LoadCombination {
150 name: format!("DL + LL + {d}SL + {label}"),
151 terms: vec![(dl, 1.0), (ll, 1.0), (snow, delta), (case, 1.0)],
152 });
153 combos.push(LoadCombination {
154 name: format!("DL + LL + {d}SL - {label}"),
155 terms: vec![(dl, 1.0), (ll, 1.0), (snow, delta), (case, -1.0)],
156 });
157 }
158 }
159}
160
161pub fn auto_combinations(
166 dl_case: LoadCaseId,
167 ll_case: LoadCaseId,
168 seismic_x: Option<LoadCaseId>,
169 seismic_y: Option<LoadCaseId>,
170 snow_case: Option<LoadCaseId>,
171) -> Vec<LoadCombination> {
172 let input = ComboInput {
173 dl: dl_case,
174 ll: ll_case,
175 seismic_x,
176 seismic_y,
177 snow: snow_case,
178 heavy_snow_zone: false,
179 snow_factors: None,
180 };
181 standard_combinations(&input)
182}
183
184pub fn is_short_term_combo(name: &str) -> bool {
195 let upper = name.to_uppercase();
196 if upper.contains('K') || upper.contains('E') || upper.contains('W') {
198 return true;
199 }
200 if let Some(pos) = upper.find('S') {
203 let coef: String = upper[..pos]
204 .chars()
205 .rev()
206 .take_while(|c| c.is_ascii_digit() || *c == '.')
207 .collect::<Vec<_>>()
208 .into_iter()
209 .rev()
210 .collect();
211 if let Ok(v) = coef.parse::<f64>() {
212 return v >= 1.0;
213 }
214 return true;
215 }
216 false
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn test_auto_combos() {
225 let combos = auto_combinations(
226 LoadCaseId(1),
227 LoadCaseId(2),
228 Some(LoadCaseId(3)),
229 Some(LoadCaseId(4)),
230 None,
231 );
232 assert_eq!(combos.len(), 5);
234 assert_eq!(combos[0].name, "DL + LL");
235 assert_eq!(combos[1].name, "DL + LL + EX");
236 assert_eq!(combos[2].name, "DL + LL - EX");
237 assert_eq!(combos[3].name, "DL + LL + EY");
238 assert_eq!(combos[4].name, "DL + LL - EY");
239 assert_eq!(combos[2].terms[2].1, -1.0);
241 assert_eq!(combos[4].terms[2].1, -1.0);
242 }
243
244 #[test]
245 fn test_is_short_term_combo() {
246 assert!(!is_short_term_combo("DL + LL"));
247 assert!(is_short_term_combo("DL + LL + EX"));
248 assert!(is_short_term_combo("DL + LL - EX"));
249 assert!(is_short_term_combo("DL + LL + EY"));
250 assert!(is_short_term_combo("DL + LL + SL"));
251 assert!(is_short_term_combo("DL + LL + WX"));
252 assert!(is_short_term_combo("DL + LL - WY"));
253 assert!(!is_short_term_combo("DL + LL + 0.7SL"));
255 assert!(is_short_term_combo("DL + LL + 0.35SL + EX"));
256 assert!(is_short_term_combo("DL + LL + 0.35SL - WY"));
257 assert!(!is_short_term_combo("G + P"));
259 assert!(is_short_term_combo("G + P + Kx"));
260 assert!(is_short_term_combo("G + P + Wx"));
261 assert!(!is_short_term_combo("G + P + 0.7S"));
262 }
263
264 #[test]
265 fn test_auto_combos_no_snow_matches_legacy_shape() {
266 let combos = auto_combinations(
269 LoadCaseId(1),
270 LoadCaseId(2),
271 Some(LoadCaseId(3)),
272 Some(LoadCaseId(4)),
273 None,
274 );
275 assert_eq!(combos.len(), 5);
276 let names: Vec<&str> = combos.iter().map(|c| c.name.as_str()).collect();
277 assert_eq!(
278 names,
279 vec![
280 "DL + LL",
281 "DL + LL + EX",
282 "DL + LL - EX",
283 "DL + LL + EY",
284 "DL + LL - EY"
285 ]
286 );
287 }
288
289 #[test]
290 fn test_standard_combinations_all_cases_heavy_snow() {
291 let input = ComboInput {
292 dl: LoadCaseId(1),
293 ll: LoadCaseId(2),
294 seismic_x: Some(LoadCaseId(3)),
295 seismic_y: Some(LoadCaseId(4)),
296 snow: Some(LoadCaseId(7)),
297 heavy_snow_zone: true,
298 snow_factors: None,
299 };
300 let combos = standard_combinations(&input);
301 assert_eq!(combos.len(), 11);
303
304 let by_name = |n: &str| {
305 combos
306 .iter()
307 .find(|c| c.name == n)
308 .unwrap_or_else(|| panic!("missing combo {n}"))
309 };
310
311 assert_eq!(
312 by_name("DL + LL").terms,
313 vec![(LoadCaseId(1), 1.0), (LoadCaseId(2), 1.0)]
314 );
315 assert_eq!(
316 by_name("DL + LL + 0.7SL").terms,
317 vec![
318 (LoadCaseId(1), 1.0),
319 (LoadCaseId(2), 1.0),
320 (LoadCaseId(7), 0.7)
321 ]
322 );
323 assert_eq!(
324 by_name("DL + LL + SL").terms,
325 vec![
326 (LoadCaseId(1), 1.0),
327 (LoadCaseId(2), 1.0),
328 (LoadCaseId(7), 1.0)
329 ]
330 );
331 assert_eq!(
332 by_name("DL + LL + EX").terms,
333 vec![
334 (LoadCaseId(1), 1.0),
335 (LoadCaseId(2), 1.0),
336 (LoadCaseId(3), 1.0)
337 ]
338 );
339 assert_eq!(
340 by_name("DL + LL - EX").terms,
341 vec![
342 (LoadCaseId(1), 1.0),
343 (LoadCaseId(2), 1.0),
344 (LoadCaseId(3), -1.0)
345 ]
346 );
347 assert_eq!(
348 by_name("DL + LL + 0.35SL + EX").terms,
349 vec![
350 (LoadCaseId(1), 1.0),
351 (LoadCaseId(2), 1.0),
352 (LoadCaseId(7), 0.35),
353 (LoadCaseId(3), 1.0)
354 ]
355 );
356 assert_eq!(
357 by_name("DL + LL + 0.35SL - EY").terms,
358 vec![
359 (LoadCaseId(1), 1.0),
360 (LoadCaseId(2), 1.0),
361 (LoadCaseId(7), 0.35),
362 (LoadCaseId(4), -1.0)
363 ]
364 );
365 assert!(combos.iter().all(|c| !c.name.contains('W')));
367 }
368
369 #[test]
370 fn test_snow_factors_direct_input() {
371 let input = ComboInput {
374 dl: LoadCaseId(1),
375 ll: LoadCaseId(2),
376 seismic_x: Some(LoadCaseId(3)),
377 seismic_y: None,
378 snow: Some(LoadCaseId(7)),
379 heavy_snow_zone: true,
380 snow_factors: Some(SnowFactors {
381 delta1: 0.65,
382 delta3: 0.4,
383 }),
384 };
385 let combos = standard_combinations(&input);
386 let by_name = |n: &str| {
387 combos
388 .iter()
389 .find(|c| c.name == n)
390 .unwrap_or_else(|| panic!("missing combo {n}"))
391 };
392 assert_eq!(by_name("DL + LL + 0.65SL").terms[2], (LoadCaseId(7), 0.65));
394 assert_eq!(
396 by_name("DL + LL + 0.4SL + EX").terms[2],
397 (LoadCaseId(7), 0.4)
398 );
399 assert!(!is_short_term_combo("DL + LL + 0.65SL"));
401 assert!(is_short_term_combo("DL + LL + 0.4SL + EX"));
402 assert!(is_short_term_combo("DL + LL + SL"));
403 }
404
405 #[test]
406 fn test_standard_combinations_no_heavy_snow() {
407 let input = ComboInput {
408 dl: LoadCaseId(1),
409 ll: LoadCaseId(2),
410 seismic_x: Some(LoadCaseId(3)),
411 seismic_y: Some(LoadCaseId(4)),
412 snow: Some(LoadCaseId(5)),
413 heavy_snow_zone: false,
414 snow_factors: None,
415 };
416 let combos = standard_combinations(&input);
417 assert_eq!(combos.len(), 6);
419 assert!(combos.iter().all(|c| !c.name.contains("0.35SL")));
420 assert!(combos.iter().all(|c| !c.name.contains("0.7SL")));
421 assert!(combos.iter().all(|c| !c.name.contains('W')));
422 }
423
424 #[test]
425 fn test_default_combinations_matches_auto_combinations() {
426 let expected = auto_combinations(
434 LoadCaseId(0),
435 LoadCaseId(1),
436 Some(LoadCaseId(3)),
437 Some(LoadCaseId(4)),
438 None,
439 );
440 let actual = crate::model::default_combinations();
441 assert_eq!(
442 actual, expected,
443 "default_combinations が auto_combinations(DL/LL/EX/EY)と一致していない"
444 );
445 assert!(!is_short_term_combo(&actual[0].name));
447 for c in &actual[1..] {
448 assert!(is_short_term_combo(&c.name), "{} は短期のはず", c.name);
449 }
450 }
451
452 #[test]
453 fn test_standard_combinations_empty_optional_cases() {
454 let input = ComboInput {
455 dl: LoadCaseId(1),
456 ll: LoadCaseId(2),
457 seismic_x: None,
458 seismic_y: None,
459 snow: None,
460 heavy_snow_zone: false,
461 snow_factors: None,
462 };
463 let combos = standard_combinations(&input);
464 assert_eq!(combos.len(), 1);
465 assert_eq!(combos[0].name, "DL + LL");
466 }
467}