Skip to main content

teamcraft/mcp/tools/
simulation_tools.rs

1//! Simulations and their results, and draft result values.
2
3use super::*;
4
5fn basis_brief(b: &simulations::SimulationBasis) -> Value {
6    json!({
7        "role": b.role,
8        "item": b.basis_number,
9        "revision": b.basis_code,
10        "name": b.basis_name,
11        "type": b.basis_type,
12        "state": b.basis_state,
13        "file": (!b.file_name.is_empty()).then_some(&b.file_name),
14        "file_version": b.file_version,
15        "file_current_version": b.file_current_version,
16        "newest_released_revision": b.released_code,
17        "note": b.note,
18        "out_of_date": b.out_of_date,
19    })
20}
21
22fn result_brief(r: &simulations::SimulationResult) -> Value {
23    json!({
24        "name": r.name,
25        "value": r.value,
26        "unit": r.unit,
27        "lower_limit": r.lower_limit,
28        "upper_limit": r.upper_limit,
29        "load_case": r.load_case,
30        "status": r.status.map(|s| s.as_str()),
31        "solver": r.solver,
32        "solver_version": r.solver_version,
33        "note": r.note,
34    })
35}
36
37/// The basis links of one simulation, grouped (the service returns one row
38/// per link, ordered by simulation number).
39pub fn group_based(rows: &[simulations::BasedSimulation]) -> Vec<Value> {
40    let mut out: Vec<Value> = Vec::new();
41    for row in rows {
42        let same = out
43            .last()
44            .is_some_and(|v| v["simulation"] == row.simulation_number.as_str());
45        if !same {
46            out.push(json!({
47                "simulation": row.simulation_number,
48                "revision": row.simulation_code,
49                "name": row.simulation_name,
50                "state": row.simulation_state,
51                "result_count": row.result_count,
52                "failed_count": row.failed_count,
53                "check_open": false,
54                "out_of_date": false,
55                "bases": [],
56            }));
57        }
58        let entry = out.last_mut().expect("pushed above");
59        if row.open_impact_id.is_some() {
60            entry["check_open"] = json!(true);
61        }
62        if row.basis.out_of_date.is_some() {
63            entry["out_of_date"] = json!(true);
64        }
65        if let Some(bases) = entry["bases"].as_array_mut() {
66            bases.push(basis_brief(&row.basis));
67        }
68    }
69    out
70}
71
72/// Simulations based on an item, for `get_item` / `get_revision`.
73pub(super) async fn simulations_of(
74    st: &AppState,
75    me: &Actor,
76    item_id: uuid::Uuid,
77) -> AppResult<(Vec<Value>, bool)> {
78    let (rows, _) = simulations::based_on_item(&st.db, me, item_id).await?;
79    Ok(capped(group_based(&rows), MAX_RELATED))
80}
81
82fn ensure_simulation(rev: &Revision) -> AppResult<()> {
83    if rev.item_type == ItemType::Simulation.as_str() {
84        Ok(())
85    } else {
86        Err(bad(format!(
87            "{} is a {}, not a simulation; list_simulations with item={} lists the simulations based on it.",
88            revisions::label(rev),
89            rev.item_type,
90            rev.item_number
91        )))
92    }
93}
94
95/// Basis links and results of a simulation revision.
96pub(super) async fn simulation_state(
97    st: &AppState,
98    me: &Actor,
99    rev: &Revision,
100) -> AppResult<Value> {
101    let (bases, results) = tokio::try_join!(
102        simulations::bases(&st.db, me, rev.id),
103        simulations::results(&st.db, me, rev.id),
104    )?;
105    let reasons: Vec<&String> = bases
106        .iter()
107        .filter_map(|b| b.out_of_date.as_ref())
108        .collect();
109    let (shown, results_cut) = capped(results.iter().collect::<Vec<_>>(), simulations::MAX_RESULTS);
110    Ok(json!({
111        "out_of_date": !reasons.is_empty(),
112        "out_of_date_reasons": reasons,
113        "summary": simulations::summarize(&results),
114        "bases": bases.iter().map(basis_brief).collect::<Vec<_>>(),
115        "results": shown.into_iter().map(result_brief).collect::<Vec<_>>(),
116        "results_truncated": results_cut,
117    }))
118}
119
120pub(super) async fn list_simulations(st: &AppState, me: &Actor, a: &Args<'_>) -> AppResult<Value> {
121    let out_of_date_only = a.flag("out_of_date_only", false)?;
122    let limit = a.limit("limit", 20, MAX_SIMULATIONS as i64)? as usize;
123    if let Some(number) = a.opt_str("item")? {
124        let item = items::get_by_number(&st.db, me, &number).await?;
125        let (rows, _) = simulations::based_on_item(&st.db, me, item.id).await?;
126        let mut list = group_based(&rows);
127        if out_of_date_only {
128            list.retain(|s| s["out_of_date"] == true);
129        }
130        let (list, truncated) = capped(list, limit);
131        let mut value = json!({
132            "based_on": item.number,
133            "simulations": list,
134            "truncated": truncated,
135        });
136        if item.item_type == ItemType::Simulation.as_str() {
137            value["hint"] = json!(format!(
138                "{} is itself a simulation: get_simulation shows its bases and results.",
139                item.number
140            ));
141        }
142        return Ok(value);
143    }
144    let project_id = match a.opt_str("project")? {
145        Some(code) => Some(projects::get_by_code(&st.db, me, &code).await?.id),
146        None => None,
147    };
148    // With the filter every simulation up to the cap is checked.
149    let scan = if out_of_date_only {
150        MAX_SIMULATIONS
151    } else {
152        limit
153    };
154    let filter = items::ItemFilter {
155        query: a.opt_str("q")?,
156        item_type: Some(ItemType::Simulation),
157        project_id,
158        state: None,
159        system: None,
160        ..Default::default()
161    };
162    let (rows, total) =
163        items::search(&st.db, me, &filter, Page::new(Some(scan as i64), None)).await?;
164    let mut list = Vec::new();
165    for sim in &rows {
166        let (bases, results) = tokio::try_join!(
167            simulations::bases(&st.db, me, sim.latest_revision_id),
168            simulations::results(&st.db, me, sim.latest_revision_id),
169        )?;
170        let reasons: Vec<String> = bases.iter().filter_map(|b| b.out_of_date.clone()).collect();
171        if out_of_date_only && reasons.is_empty() {
172            continue;
173        }
174        list.push(json!({
175            "simulation": sim.number,
176            "revision": sim.latest_code,
177            "name": sim.name,
178            "state": sim.latest_state,
179            "project": sim.project_code,
180            "out_of_date": !reasons.is_empty(),
181            "out_of_date_reasons": reasons,
182            "based_on": bases.iter().map(|b| format!("{}/{}", b.basis_number, b.basis_code)).collect::<Vec<_>>(),
183            "results": simulations::summarize(&results),
184        }));
185    }
186    let (list, cut) = capped(list, limit);
187    Ok(json!({
188        "total_simulations": total,
189        "checked": rows.len(),
190        "simulations": list,
191        "truncated": cut || total > rows.len() as i64,
192    }))
193}
194
195pub(super) async fn get_simulation(st: &AppState, me: &Actor, a: &Args<'_>) -> AppResult<Value> {
196    let rev = revision_arg(st, me, a).await?;
197    ensure_simulation(&rev)?;
198    let mut value = simulation_state(st, me, &rev).await?;
199    value["simulation"] = rev_brief(&rev);
200    value["url"] = json!(web_url(st, &format!("/items/{}", rev.item_id)));
201    Ok(value)
202}
203
204/// One matched result value of [`simulations::compare_results`].
205pub fn diff_brief(d: &simulations::ResultDiff) -> Value {
206    let side = |r: &Option<simulations::SimulationResult>| {
207        r.as_ref().map(|r| {
208            json!({
209                "value": r.value,
210                "unit": r.unit,
211                "lower_limit": r.lower_limit,
212                "upper_limit": r.upper_limit,
213                "status": r.status.map(|s| s.as_str()),
214            })
215        })
216    };
217    json!({
218        "change": d.change,
219        "name": d.name,
220        "load_case": d.load_case,
221        "unit": d.unit,
222        "before": side(&d.before),
223        "after": side(&d.after),
224        "delta": d.delta,
225        "delta_percent": d.delta_percent.map(|p| (p * 100.0).round() / 100.0),
226    })
227}
228
229pub(super) async fn compare_simulation_results(
230    st: &AppState,
231    me: &Actor,
232    a: &Args<'_>,
233) -> AppResult<Value> {
234    let (from, to) = revision_pair(st, me, a).await?;
235    ensure_simulation(&to)?;
236    let changes_only = a.flag("changes_only", false)?;
237    let diffs = simulations::compare(&st.db, &from, &to).await?;
238    let summary = simulations::compare_summary(&diffs);
239    let shown: Vec<&simulations::ResultDiff> = diffs
240        .iter()
241        .filter(|d| !changes_only || d.change != crate::services::bom_compare::Change::Unchanged)
242        .collect();
243    let (shown, truncated) = capped(shown, simulations::MAX_RESULTS);
244    Ok(json!({
245        "from": revisions::label(&from),
246        "to": revisions::label(&to),
247        "from_state": from.state,
248        "to_state": to.state,
249        "summary": summary,
250        "diffs": shown.into_iter().map(diff_brief).collect::<Vec<_>>(),
251        "truncated": truncated,
252    }))
253}
254
255/// `propose_result_values`: drafts for a user to accept; never a result.
256pub(super) async fn propose_result_values(
257    st: &AppState,
258    me: &Actor,
259    a: &Args<'_>,
260) -> AppResult<Value> {
261    let rev = revision_arg(st, me, a).await?;
262    let Some(Value::Array(list)) = a.get("values") else {
263        return Err(bad("`values` must be a list of values."));
264    };
265    let mut inputs = Vec::with_capacity(list.len());
266    for (i, v) in list.iter().enumerate() {
267        let input: result_proposals::ProposalInput =
268            serde_json::from_value(v.clone()).map_err(|e| bad(format!("values[{i}]: {e}")))?;
269        inputs.push(input);
270    }
271    let proposals = result_proposals::propose(&st.db, me, rev.id, inputs).await?;
272    Ok(json!({
273        "item": rev.item_number,
274        "revision": rev.code,
275        "proposed": proposals.iter().map(|p| json!({
276            "name": p.name, "value": p.value, "unit": p.unit, "load_case": p.load_case,
277            "file": p.source_file, "version": p.source_version, "status": p.status,
278        })).collect::<Vec<_>>(),
279        "note": "These are drafts. A CAD editor accepts or rejects them on the simulation page in TeamCraft \
280                 (accepting needs the revision checked out). You cannot accept them – tell the user where \
281                 to find them.",
282        "url": web_url(st, &format!("/revisions/{}#result-proposals", rev.id)),
283    }))
284}