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}