Skip to main content

hpr_net/
on_demand.rs

1//! One motor found by name on [ThrustCurve.org](https://www.thrustcurve.org), with the curve file
2//! to fly it by: for a simulator that lacks the motor's curve ([M4.5b][m4-5b], motors on demand;
3//! [ADR-154][adr-154], the decision on fetching them).
4//!
5//! [`find`] asks ThrustCurve's [search](crate::thrustcurve::Search) for the name as a designation
6//! (`F27R/L`), then, if no motor has that designation, as a common name (`F27`), each time with
7//! the manufacturer when one is given. Exactly one solid motor must answer: two or more are
8//! [`FindError::Ambiguous`], listed by maker and designation so the caller can say which, and a
9//! hybrid is refused, as hpr flies commercial solid motors only. It then
10//! [downloads](crate::thrustcurve::Download) the motor's RASP (`.eng`) files and takes the first
11//! that reads as one motor, ranked by who measured it ([`rank`]): a certification test, then the
12//! manufacturer, then a user, then a file that names no source, in the answer's order within each.
13//! With no RASP file that reads, it does the same with the RockSim (`.rse`) files. A file the
14//! caller names ([`Wanted::prefer`]) goes ahead of that order when it reads, the RockSim files
15//! read too when the RASP ones lack it (offline with no copy of them, the RASP files are taken
16//! as if none were named). [`find_with`]
17//! takes the caller's own reading of a file instead, so the file taken is the first the caller
18//! can use: a simulator that builds a motor from the file's masses refuses more than
19//! `hpr_motor`'s reader does.
20//!
21//! Every answer goes through the [`Client`], so it is cached ([`crate::thrustcurve::TTL_S`], a
22//! day) and, offline, read from the cache alone: a motor found once is found again with no
23//! network. Nothing is bundled: ThrustCurve states no license for its records, and each file
24//! carries its own ([`DataFile::license`]), which the caller shows.
25//!
26//! **How far to trust it:** the curve is the file a contributor uploaded, as ThrustCurve serves
27//! it; which file is taken is the rule above, not a judgement of which is right. A name is matched
28//! as ThrustCurve's search matches it (on 2026-10-04, a designation exactly but for case), so a
29//! name ThrustCurve spells differently is not found rather than wrongly matched.
30//!
31//! [m4-5b]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m4-5b
32//! [adr-154]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0154-motors-fetched-from-thrustcurve-by-name.md
33
34use serde::{Deserialize, Serialize};
35
36use crate::thrustcurve::{
37    self, DataFile, Download, Format, MotorRecord, Search, SearchAnswer, ThrustCurveError,
38};
39use crate::{Client, Fetched, NetError, Transport};
40
41/// The motor to find: a designation or common name, and its manufacturer if known.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Wanted {
45    /// The manufacturer's name or abbreviation, as ThrustCurve's search takes it (`AeroTech`).
46    pub manufacturer: Option<String>,
47    /// The designation (`F27R/L`) or common name (`F27`).
48    pub name: String,
49    /// A data file to take ahead of [`rank`]'s order when the motor's answer holds it and it
50    /// reads: its ThrustCurve id ([`DataFile::simfile_id`]). A caller that knows which file holds
51    /// the curve it wants, such as the one another simulator flies, names it here.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub prefer: Option<String>,
54}
55
56impl Wanted {
57    /// A motor by its designation or common name alone.
58    #[must_use]
59    pub fn named(name: &str) -> Self {
60        Self {
61            manufacturer: None,
62            name: name.to_owned(),
63            prefer: None,
64        }
65    }
66
67    /// A motor by its manufacturer and its designation or common name.
68    #[must_use]
69    pub fn by(manufacturer: &str, name: &str) -> Self {
70        Self {
71            manufacturer: Some(manufacturer.to_owned()),
72            name: name.to_owned(),
73            prefer: None,
74        }
75    }
76
77    /// The same motor, taking the data file `simfile_id` first when the answer holds it
78    /// ([`Wanted::prefer`]).
79    #[must_use]
80    pub fn preferring(mut self, simfile_id: &str) -> Self {
81        self.prefer = Some(simfile_id.to_owned());
82        self
83    }
84
85    /// The motor in words: `AeroTech F27R/L`, or the name alone.
86    #[must_use]
87    pub fn words(&self) -> String {
88        match &self.manufacturer {
89            Some(maker) => format!("{maker} {}", self.name),
90            None => self.name.clone(),
91        }
92    }
93}
94
95/// A motor found, with the file to fly it by.
96#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq)]
98pub struct Found {
99    /// ThrustCurve's record of the motor.
100    pub record: MotorRecord,
101    /// The data file taken ([`find`]'s rule): its format, source, license and text.
102    pub file: DataFile,
103    /// Every answer read, in the order asked, each saying whether it came from the network or
104    /// the cache.
105    pub fetched: Vec<Fetched>,
106}
107
108/// Why no motor was found.
109#[non_exhaustive]
110#[derive(Debug, thiserror::Error)]
111pub enum FindError {
112    /// No motor has the name as its designation or its common name.
113    #[error("ThrustCurve.org has no motor whose designation or common name is {wanted}")]
114    NotFound {
115        /// The motor asked for, in words.
116        wanted: String,
117    },
118    /// Several solid motors answer the name.
119    #[error("{matches} motors on ThrustCurve.org answer {wanted}: {}", candidates.join(", "))]
120    Ambiguous {
121        /// The motor asked for, in words.
122        wanted: String,
123        /// How many motors the search says match.
124        matches: u32,
125        /// Those the answer lists, each as `maker designation`.
126        candidates: Vec<String>,
127    },
128    /// The one motor that answers is a hybrid.
129    #[error("{motor} is a hybrid on ThrustCurve.org; hpr flies commercial solid motors only")]
130    Hybrid {
131        /// The motor, as `maker designation`.
132        motor: String,
133    },
134    /// The motor has no RASP or RockSim file that `hpr_motor` reads.
135    #[error("{motor} has no RASP or RockSim file on ThrustCurve.org that hpr reads{}", why.as_ref().map(|why| format!(": {why}")).unwrap_or_default())]
136    NoFile {
137        /// The motor, as `maker designation`.
138        motor: String,
139        /// Why the last file tried was refused; `None` when there were none.
140        why: Option<String>,
141    },
142    /// A request or answer was refused, or the fetch failed.
143    #[error(transparent)]
144    ThrustCurve(#[from] ThrustCurveError),
145}
146
147impl FindError {
148    /// Whether it failed only because, offline, an answer is not in the cache: a fetch with a
149    /// network connection would answer.
150    #[must_use]
151    pub fn is_not_cached(&self) -> bool {
152        matches!(
153            self,
154            Self::ThrustCurve(ThrustCurveError::Net(NetError::NotCached { .. }))
155        )
156    }
157}
158
159/// Where a data file's source puts it in [`find`]'s order: `cert` (a certification test) 0,
160/// `mfr` (the manufacturer) 1, `user` 2, anything else or none 3.
161#[must_use]
162pub fn rank(file: &DataFile) -> u8 {
163    match file.source.as_deref() {
164        Some("cert") => 0,
165        Some("mfr") => 1,
166        Some("user") => 2,
167        _ => 3,
168    }
169}
170
171/// Whether a data file reads as exactly one motor's curve with `hpr_motor`'s reader: [`find`]'s
172/// test of a file.
173///
174/// # Errors
175/// Why not, in words: the file doesn't read, holds no motor or several, or its curve breaks
176/// `hpr_motor`'s rules.
177pub fn reads_as_one_motor(file: &DataFile) -> Result<(), String> {
178    let curve = file.read().map_err(|error| error.to_string())?;
179    let motors = match &curve {
180        thrustcurve::Curve::Rasp(parsed) => parsed.value.entries.len(),
181        thrustcurve::Curve::RockSim(parsed) => parsed.value.engines.len(),
182    };
183    if motors != 1 {
184        return Err(format!("the file holds {motors} motors, not one"));
185    }
186    curve
187        .thrust_curve()
188        .map(drop)
189        .map_err(|error| error.to_string())
190}
191
192/// Finds `wanted` on ThrustCurve.org through `client`, and the file to fly it by, as the module
193/// says: the first file that [`reads_as_one_motor`].
194///
195/// # Errors
196/// [`FindError::NotFound`], [`FindError::Ambiguous`] or [`FindError::Hybrid`] by the search;
197/// [`FindError::NoFile`] when no file reads; [`FindError::ThrustCurve`] when a fetch fails or an
198/// answer is refused, offline with [`NetError::NotCached`] for an answer not in the cache
199/// ([`FindError::is_not_cached`]).
200pub fn find<T: Transport>(
201    client: &Client<T>,
202    wanted: &Wanted,
203    now_s: u64,
204) -> Result<Found, FindError> {
205    find_with(client, wanted, now_s, reads_as_one_motor).map(|(found, ())| found)
206}
207
208/// As [`find`], with `build` reading a file instead: the file taken is the first, in [`find`]'s
209/// order, that `build` turns into an `M`, returned with it. `build`'s refusal of the last file
210/// tried is [`FindError::NoFile`]'s reason.
211///
212/// # Errors
213/// As [`find`].
214pub fn find_with<T: Transport, M>(
215    client: &Client<T>,
216    wanted: &Wanted,
217    now_s: u64,
218    build: impl Fn(&DataFile) -> Result<M, String>,
219) -> Result<(Found, M), FindError> {
220    let mut fetched = Vec::new();
221    let mut search = |designation: bool| -> Result<SearchAnswer, FindError> {
222        let name = Some(wanted.name.clone());
223        let request = Search {
224            manufacturer: wanted.manufacturer.clone(),
225            designation: if designation { name.clone() } else { None },
226            common_name: if designation { None } else { name },
227            ..Search::default()
228        };
229        let (answer, got) = thrustcurve::fetch_search(client, &request, now_s)?;
230        fetched.push(got);
231        Ok(answer)
232    };
233    let mut answer = search(true)?;
234    if answer.results.is_empty() {
235        answer = search(false)?;
236    }
237    let record = one(answer, wanted)?;
238    let motor = maker_and_designation(&record);
239    let mut queue = ranked(client, &record, Format::Rasp, now_s, &mut fetched)?;
240    let mut rest = Some(Format::RockSim);
241    if let Some(id) = &wanted.prefer {
242        // A preferred file the RASP answer lacks is looked for in the RockSim one too, ahead of
243        // every RASP file. Offline with no copy of that answer, the RASP files are taken as
244        // before: the preference is given up, not the motor.
245        if !queue.iter().any(|file| file.simfile_id == *id) {
246            match ranked(client, &record, Format::RockSim, now_s, &mut fetched) {
247                Ok(files) => {
248                    queue.extend(files);
249                    rest = None;
250                }
251                Err(error) if error.is_not_cached() && !queue.is_empty() => {}
252                Err(error) => return Err(error),
253            }
254        }
255        // The preferred file goes first; the rest keep their order behind it.
256        if let Some(at) = queue.iter().position(|file| file.simfile_id == *id) {
257            queue[..=at].rotate_right(1);
258        }
259    }
260    let mut why = None;
261    loop {
262        for file in queue {
263            match build(&file) {
264                Ok(built) => {
265                    let found = Found {
266                        record,
267                        file,
268                        fetched,
269                    };
270                    return Ok((found, built));
271                }
272                Err(error) => why = Some(format!("{}: {error}", file.simfile_id)),
273            }
274        }
275        match rest.take() {
276            Some(format) => queue = ranked(client, &record, format, now_s, &mut fetched)?,
277            None => return Err(FindError::NoFile { motor, why }),
278        }
279    }
280}
281
282/// The motor's files in `format`, in [`rank`]'s order, the answer's within a rank; the answer read
283/// is added to `fetched`.
284fn ranked<T: Transport>(
285    client: &Client<T>,
286    record: &MotorRecord,
287    format: Format,
288    now_s: u64,
289    fetched: &mut Vec<Fetched>,
290) -> Result<Vec<DataFile>, FindError> {
291    let download = Download::new(&record.motor_id, format)?;
292    let (files, got) = thrustcurve::fetch_download(client, &download, now_s)?;
293    fetched.push(got);
294    let mut files = files.results;
295    // Stable: within a rank, the answer's order.
296    files.sort_by_key(rank);
297    Ok(files)
298}
299
300/// The one solid motor a search answers, or why there isn't one.
301fn one(answer: SearchAnswer, wanted: &Wanted) -> Result<MotorRecord, FindError> {
302    let hybrid = |record: &MotorRecord| {
303        record
304            .motor_type
305            .as_deref()
306            .is_some_and(|kind| kind.eq_ignore_ascii_case("hybrid"))
307    };
308    let (hybrids, solids): (Vec<MotorRecord>, Vec<MotorRecord>) =
309        answer.results.into_iter().partition(hybrid);
310    let unlisted = usize::try_from(answer.matches)
311        .unwrap_or(usize::MAX)
312        .saturating_sub(hybrids.len() + solids.len());
313    match (&solids[..], unlisted) {
314        ([record], 0) => Ok(record.clone()),
315        ([], 0) => match &hybrids[..] {
316            [] => Err(FindError::NotFound {
317                wanted: wanted.words(),
318            }),
319            [hybrid, ..] => Err(FindError::Hybrid {
320                motor: maker_and_designation(hybrid),
321            }),
322        },
323        _ => {
324            let mut candidates: Vec<String> = solids.iter().map(maker_and_designation).collect();
325            candidates.sort();
326            Err(FindError::Ambiguous {
327                wanted: wanted.words(),
328                matches: answer.matches,
329                candidates,
330            })
331        }
332    }
333}
334
335/// `AeroTech F27R/L`: the short maker name where the record has one.
336fn maker_and_designation(record: &MotorRecord) -> String {
337    let maker = record
338        .manufacturer_abbrev
339        .as_deref()
340        .unwrap_or(&record.manufacturer);
341    format!("{maker} {}", record.designation)
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn file(source: Option<&str>) -> DataFile {
349        serde_json::from_value(serde_json::json!({
350            "motorId": "e00000000000000000000001",
351            "simfileId": "f00000000000000000000001",
352            "format": "RASP",
353            "source": source,
354            "data": "",
355        }))
356        .unwrap()
357    }
358
359    /// A certification test's file ranks first, then the manufacturer's, a user's, and one with
360    /// no source or an unknown one.
361    #[test]
362    fn files_rank_by_who_measured_them() {
363        let ranks: Vec<u8> = [Some("cert"), Some("mfr"), Some("user"), None, Some("other")]
364            .into_iter()
365            .map(|source| rank(&file(source)))
366            .collect();
367        assert_eq!(ranks, [0, 1, 2, 3, 3]);
368    }
369
370    #[test]
371    fn a_motor_in_words_names_its_maker_when_known() {
372        assert_eq!(Wanted::named("F27R/L").words(), "F27R/L");
373        assert_eq!(Wanted::by("AeroTech", "F27R/L").words(), "AeroTech F27R/L");
374    }
375}