1use std::collections::BTreeMap;
45use std::fmt::Write as _;
46
47use base64::Engine as _;
48use hpr_motor::text::Parsed;
49use hpr_motor::{MotorError, ThrustCurve, eng, rse};
50use serde::{Deserialize, Serialize};
51
52use crate::motor_finder::{self, MANUFACTURERS};
53use crate::{Client, Fetched, NetError, Source, Transport};
54
55pub const BASE_URL: &str = "https://www.thrustcurve.org/api/v1";
57
58pub const TTL_S: u64 = 86_400;
60
61pub const MAX_RESULTS: u32 = 5_000;
65
66pub const ATTRIBUTION: &str = "Motor data and thrust curves courtesy of ThrustCurve.org, \
68 https://www.thrustcurve.org/";
69
70#[non_exhaustive]
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Search {
75 pub manufacturer: Option<String>,
77 pub designation: Option<String>,
79 pub common_name: Option<String>,
81 pub impulse_class: Option<String>,
83 pub max_results: Option<u32>,
85}
86
87impl Search {
88 #[must_use]
90 pub fn manufacturer(name: &str) -> Self {
91 Self {
92 manufacturer: Some(name.to_owned()),
93 max_results: Some(MAX_RESULTS),
94 ..Self::default()
95 }
96 }
97
98 #[must_use]
101 pub fn url(&self) -> String {
102 let max_results = self.max_results.map(|n| n.to_string());
103 let fields = [
104 ("manufacturer", self.manufacturer.as_deref()),
105 ("designation", self.designation.as_deref()),
106 ("commonName", self.common_name.as_deref()),
107 ("impulseClass", self.impulse_class.as_deref()),
108 ("maxResults", max_results.as_deref()),
109 ];
110 let query: Vec<String> = fields
111 .iter()
112 .filter_map(|(name, value)| Some(format!("{name}={}", encode(value.as_ref()?))))
113 .collect();
114 if query.is_empty() {
115 return format!("{BASE_URL}/search.json");
116 }
117 format!("{BASE_URL}/search.json?{}", query.join("&"))
118 }
119}
120
121#[non_exhaustive]
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub enum Format {
125 #[serde(rename = "RASP")]
127 Rasp,
128 #[serde(rename = "RockSim")]
130 RockSim,
131}
132
133impl Format {
134 #[must_use]
136 pub fn as_str(self) -> &'static str {
137 match self {
138 Self::Rasp => "RASP",
139 Self::RockSim => "RockSim",
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct Download {
148 motor_id: String,
149 format: Format,
150}
151
152impl Download {
153 pub fn new(motor_id: &str, format: Format) -> Result<Self, ThrustCurveError> {
160 if !is_motor_id(motor_id) {
161 return Err(ThrustCurveError::Request {
162 what: "motor id",
163 value: motor_id.to_owned(),
164 });
165 }
166 Ok(Self {
167 motor_id: motor_id.to_ascii_lowercase(),
168 format,
169 })
170 }
171
172 #[must_use]
174 pub fn motor_id(&self) -> &str {
175 &self.motor_id
176 }
177
178 #[must_use]
180 pub fn format(&self) -> Format {
181 self.format
182 }
183
184 #[must_use]
186 pub fn url(&self) -> String {
187 format!(
188 "{BASE_URL}/download.json?motorId={}&format={}&data=file",
189 self.motor_id,
190 self.format.as_str()
191 )
192 }
193}
194
195#[must_use]
198pub fn source() -> Source {
199 Source {
200 name: "ThrustCurve.org".to_owned(),
201 attribution: ATTRIBUTION.to_owned(),
202 ttl_s: TTL_S,
203 }
204}
205
206#[non_exhaustive]
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct SearchAnswer {
210 pub criteria: Vec<Criterion>,
212 pub matches: u32,
215 pub results: Vec<MotorRecord>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub source_url: Option<String>,
220}
221
222impl SearchAnswer {
223 #[must_use]
225 pub fn is_complete(&self) -> bool {
226 usize::try_from(self.matches).ok() == Some(self.results.len())
227 }
228}
229
230#[non_exhaustive]
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct Criterion {
234 pub name: String,
236 pub value: String,
238 pub matches: u32,
240}
241
242#[non_exhaustive]
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct MotorRecord {
249 pub motor_id: String,
251 pub manufacturer: String,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub manufacturer_abbrev: Option<String>,
256 pub designation: String,
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub common_name: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub impulse_class: Option<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub cert_org: Option<String>,
267 #[serde(rename = "diameter", skip_serializing_if = "Option::is_none")]
269 pub diameter_mm: Option<f64>,
270 #[serde(rename = "length", skip_serializing_if = "Option::is_none")]
272 pub length_mm: Option<f64>,
273 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
275 pub motor_type: Option<String>,
276 #[serde(rename = "avgThrustN", skip_serializing_if = "Option::is_none")]
278 pub avg_thrust_n: Option<f64>,
279 #[serde(rename = "maxThrustN", skip_serializing_if = "Option::is_none")]
281 pub max_thrust_n: Option<f64>,
282 #[serde(rename = "totImpulseNs", skip_serializing_if = "Option::is_none")]
284 pub total_impulse_ns: Option<f64>,
285 #[serde(rename = "burnTimeS", skip_serializing_if = "Option::is_none")]
287 pub burn_time_s: Option<f64>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub data_files: Option<u32>,
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub info_url: Option<String>,
294 #[serde(rename = "totalWeightG", skip_serializing_if = "Option::is_none")]
296 pub total_weight_g: Option<f64>,
297 #[serde(rename = "propWeightG", skip_serializing_if = "Option::is_none")]
299 pub propellant_weight_g: Option<f64>,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub delays: Option<String>,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub delay_adjustable: Option<bool>,
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub case_info: Option<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub prop_info: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub sparky: Option<bool>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub updated_on: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub availability: Option<String>,
321 #[serde(rename = "source_url", skip_serializing_if = "Option::is_none")]
323 pub source_url: Option<String>,
324}
325
326#[non_exhaustive]
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct DownloadAnswer {
330 pub results: Vec<DataFile>,
332}
333
334#[non_exhaustive]
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct DataFile {
339 pub motor_id: String,
341 pub simfile_id: String,
343 pub format: Format,
345 #[serde(skip_serializing_if = "Option::is_none")]
348 pub source: Option<String>,
349 #[serde(skip_serializing_if = "Option::is_none")]
352 pub license: Option<String>,
353 pub data: String,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub info_url: Option<String>,
358 #[serde(skip_serializing_if = "Option::is_none")]
360 pub data_url: Option<String>,
361 #[serde(rename = "source_url", skip_serializing_if = "Option::is_none")]
363 pub source_url: Option<String>,
364}
365
366#[non_exhaustive]
368#[derive(Debug, Clone, PartialEq)]
369pub enum Curve {
370 Rasp(Parsed<eng::EngFile>),
372 RockSim(Parsed<rse::RseFile>),
374}
375
376impl Curve {
377 pub fn thrust_curve(&self) -> Result<ThrustCurve, MotorError> {
382 let none = || MotorError::Inconsistent("the file holds no motor".to_owned());
383 match self {
384 Self::Rasp(parsed) => parsed
385 .value
386 .entries
387 .first()
388 .ok_or_else(none)?
389 .thrust_curve(),
390 Self::RockSim(parsed) => parsed
391 .value
392 .engines
393 .first()
394 .ok_or_else(none)?
395 .thrust_curve(),
396 }
397 }
398}
399
400impl DataFile {
401 pub fn text(&self) -> Result<String, ThrustCurveError> {
408 let bytes = base64::engine::general_purpose::STANDARD
409 .decode(&self.data)
410 .map_err(|e| field(format!("{}.data", self.simfile_id), e))?;
411 String::from_utf8(bytes)
412 .map_err(|e| field(format!("{}.data", self.simfile_id), e.utf8_error()))
413 }
414
415 pub fn read(&self) -> Result<Curve, ThrustCurveError> {
420 let text = self.text()?;
421 Ok(match self.format {
422 Format::Rasp => Curve::Rasp(eng::parse(&text)?),
423 Format::RockSim => Curve::RockSim(rse::parse(&text)?),
424 })
425 }
426}
427
428pub fn parse_search(body: &[u8]) -> Result<SearchAnswer, ThrustCurveError> {
437 #[derive(Deserialize)]
438 struct Errors {
439 error: Option<String>,
440 #[serde(default)]
441 criteria: Vec<CriterionError>,
442 }
443 #[derive(Deserialize)]
444 struct CriterionError {
445 name: String,
446 error: Option<String>,
447 }
448 let errors: Errors = json(body)?;
449 if let Some(error) = errors.error {
450 return Err(ThrustCurveError::Api(error));
451 }
452 if let Some(c) = errors.criteria.into_iter().find(|c| c.error.is_some()) {
453 let error = c.error.unwrap_or_default();
454 return Err(ThrustCurveError::Api(format!("{}: {error}", c.name)));
455 }
456 let answer: SearchAnswer = json(body)?;
457 let found = answer.results.len();
458 if usize::try_from(answer.matches).map_or(true, |m| found > m) {
459 return Err(ThrustCurveError::Count {
460 matches: answer.matches,
461 found,
462 });
463 }
464 for (i, motor) in answer.results.iter().enumerate() {
465 check_record(motor, &format!("results[{i}]"))?;
466 }
467 Ok(answer)
468}
469
470pub fn parse_download(body: &[u8]) -> Result<DownloadAnswer, ThrustCurveError> {
478 #[derive(Deserialize)]
479 struct Errors {
480 error: Option<String>,
481 }
482 if let Some(error) = json::<Errors>(body)?.error {
483 return Err(ThrustCurveError::Api(error));
484 }
485 let answer: DownloadAnswer = json(body)?;
486 for (i, file) in answer.results.iter().enumerate() {
487 if !is_motor_id(&file.motor_id) {
488 return Err(field(format!("results[{i}].motorId"), &file.motor_id));
489 }
490 base64::engine::general_purpose::STANDARD
491 .decode(&file.data)
492 .map_err(|e| field(format!("results[{i}].data"), e))?;
493 }
494 Ok(answer)
495}
496
497pub fn fetch_search<T: Transport>(
508 client: &Client<T>,
509 search: &Search,
510 now_s: u64,
511) -> Result<(SearchAnswer, Fetched), ThrustCurveError> {
512 fetch(client, &search.url(), now_s, parse_search)
513}
514
515pub fn fetch_download<T: Transport>(
522 client: &Client<T>,
523 download: &Download,
524 now_s: u64,
525) -> Result<(DownloadAnswer, Fetched), ThrustCurveError> {
526 let read = |body: &[u8]| {
527 let answer = parse_download(body)?;
528 let other = answer.results.iter().find(|f| {
529 !f.motor_id.eq_ignore_ascii_case(&download.motor_id) || f.format != download.format
530 });
531 if let Some(file) = other {
532 return Err(ThrustCurveError::OtherMotor {
533 asked: format!("{} ({})", download.motor_id, download.format.as_str()),
534 found: format!("{} ({})", file.motor_id, file.format.as_str()),
535 });
536 }
537 Ok(answer)
538 };
539 fetch(client, &download.url(), now_s, read)
540}
541
542pub fn fetch_finder_records<T: Transport>(
551 client: &Client<T>,
552 now_s: u64,
553) -> Result<(Vec<MotorRecord>, Vec<Fetched>), ThrustCurveError> {
554 let mut records = Vec::new();
555 let mut fetched = Vec::new();
556 for &(name, _) in MANUFACTURERS {
557 let read = |body: &[u8]| {
558 let answer = parse_search(body)?;
559 if !answer.is_complete() {
560 return Err(ThrustCurveError::Incomplete {
561 matches: answer.matches,
562 found: answer.results.len(),
563 });
564 }
565 if let Some(other) = answer.results.iter().find(|r| r.manufacturer != name) {
566 return Err(ThrustCurveError::OtherManufacturer {
567 asked: name.to_owned(),
568 found: other.manufacturer.clone(),
569 });
570 }
571 Ok(answer)
572 };
573 let (answer, f) = fetch(client, &Search::manufacturer(name).url(), now_s, read)?;
574 records.extend(answer.results);
575 fetched.push(f);
576 }
577 Ok((records, fetched))
578}
579
580#[non_exhaustive]
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
583pub struct Join {
584 pub mapped: Vec<Mapped>,
586 pub misses: Vec<Miss>,
588}
589
590#[non_exhaustive]
592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct Mapped {
594 pub manufacturer: String,
596 pub designation: String,
598 pub finder_index: usize,
600 pub record: MotorRecord,
602}
603
604#[non_exhaustive]
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
607pub struct Miss {
608 pub manufacturer: String,
610 pub designation: String,
612 pub reason: MissReason,
614}
615
616#[non_exhaustive]
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub enum MissReason {
620 NoRecord,
622 SeveralRecords(Vec<String>),
624}
625
626impl Join {
627 #[must_use]
629 pub fn coverage(&self) -> (usize, usize) {
630 (self.mapped.len(), self.mapped.len() + self.misses.len())
631 }
632
633 #[must_use]
637 pub fn report(&self) -> String {
638 let mut by_maker: BTreeMap<&str, (usize, usize, usize)> = BTreeMap::new();
639 for m in &self.mapped {
640 let row = by_maker.entry(&m.manufacturer).or_default();
641 row.0 += 1;
642 row.1 += 1;
643 if m.record.data_files.unwrap_or(0) == 0 {
644 row.2 += 1;
645 }
646 }
647 for m in &self.misses {
648 by_maker.entry(&m.manufacturer).or_default().1 += 1;
649 }
650 let (mapped, total) = self.coverage();
651 let mut out = String::new();
652 let _ = writeln!(
654 out,
655 "{mapped} of {total} motors mapped to one record each.\n"
656 );
657 let _ = writeln!(
658 out,
659 "| Manufacturer | Motors | Mapped | Mapped, no data file listed | Missed |"
660 );
661 let _ = writeln!(out, "|---|---:|---:|---:|---:|");
662 for (maker, (mapped, total, no_file)) in &by_maker {
663 let missed = total - mapped;
664 let _ = writeln!(
665 out,
666 "| {maker} | {total} | {mapped} | {no_file} | {missed} |"
667 );
668 }
669 let _ = writeln!(out);
670 if self.misses.is_empty() {
671 let _ = writeln!(out, "No misses.");
672 } else {
673 let _ = writeln!(out, "| Manufacturer | Designation | Why |");
674 let _ = writeln!(out, "|---|---|---|");
675 for m in &self.misses {
676 let why = match &m.reason {
677 MissReason::NoRecord => "no record of that name".to_owned(),
678 MissReason::SeveralRecords(ids) => {
679 format!("{} records of that name: {}", ids.len(), ids.join(", "))
680 }
681 };
682 let _ = writeln!(out, "| {} | `{}` | {why} |", m.manufacturer, m.designation);
683 }
684 }
685 out
686 }
687}
688
689#[must_use]
694pub fn join(motors: &[motor_finder::Motor], records: &[MotorRecord]) -> Join {
695 let mut by_name: BTreeMap<(&str, &str), Vec<&MotorRecord>> = BTreeMap::new();
696 for r in records {
697 let same_name = by_name
698 .entry((&r.manufacturer, &r.designation))
699 .or_default();
700 if same_name
701 .iter()
702 .all(|kept| !kept.motor_id.eq_ignore_ascii_case(&r.motor_id))
703 {
704 same_name.push(r);
705 }
706 }
707 let mut join = Join {
708 mapped: Vec::new(),
709 misses: Vec::new(),
710 };
711 for (finder_index, m) in motors.iter().enumerate() {
712 let found = by_name
713 .get(&(m.manufacturer.as_str(), m.designation.as_str()))
714 .map_or(&[][..], Vec::as_slice);
715 let miss = |reason| Miss {
716 manufacturer: m.manufacturer.clone(),
717 designation: m.designation.clone(),
718 reason,
719 };
720 match found {
721 [record] => join.mapped.push(Mapped {
722 manufacturer: m.manufacturer.clone(),
723 designation: m.designation.clone(),
724 finder_index,
725 record: (*record).clone(),
726 }),
727 [] => join.misses.push(miss(MissReason::NoRecord)),
728 several => join.misses.push(miss(MissReason::SeveralRecords(
729 several.iter().map(|r| r.motor_id.clone()).collect(),
730 ))),
731 }
732 }
733 join
734}
735
736fn fetch<T: Transport, R>(
738 client: &Client<T>,
739 url: &str,
740 now_s: u64,
741 read: impl Fn(&[u8]) -> Result<R, ThrustCurveError>,
742) -> Result<(R, Fetched), ThrustCurveError> {
743 let check = |body: &[u8]| read(body).map(drop).map_err(|e| e.to_string());
744 let fetched = client.fetch_checked(&source(), url, now_s, check)?;
745 let value = read(&fetched.body)?;
746 Ok((value, fetched))
747}
748
749fn is_motor_id(id: &str) -> bool {
751 id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit())
752}
753
754fn encode(value: &str) -> String {
757 let mut out = String::with_capacity(value.len());
758 for b in value.bytes() {
759 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
760 out.push(char::from(b));
761 } else {
762 let _ = write!(out, "%{b:02X}");
763 }
764 }
765 out
766}
767
768fn check_record(motor: &MotorRecord, at: &str) -> Result<(), ThrustCurveError> {
770 if !is_motor_id(&motor.motor_id) {
771 return Err(field(format!("{at}.motorId"), &motor.motor_id));
772 }
773 let figures = [
774 ("diameter", motor.diameter_mm),
775 ("length", motor.length_mm),
776 ("avgThrustN", motor.avg_thrust_n),
777 ("maxThrustN", motor.max_thrust_n),
778 ("totImpulseNs", motor.total_impulse_ns),
779 ("burnTimeS", motor.burn_time_s),
780 ("totalWeightG", motor.total_weight_g),
781 ("propWeightG", motor.propellant_weight_g),
782 ];
783 for (name, value) in figures {
784 if let Some(value) = value.filter(|v| *v < 0.0) {
785 return Err(field(format!("{at}.{name}"), value));
786 }
787 }
788 Ok(())
789}
790
791fn json<'a, D: Deserialize<'a>>(body: &'a [u8]) -> Result<D, ThrustCurveError> {
793 serde_json::from_slice(body).map_err(|e| ThrustCurveError::Json(e.to_string()))
794}
795
796fn field(field: String, value: impl ToString) -> ThrustCurveError {
798 ThrustCurveError::Field {
799 field,
800 value: value.to_string(),
801 }
802}
803
804#[non_exhaustive]
806#[derive(Debug, thiserror::Error)]
807pub enum ThrustCurveError {
808 #[error("the ThrustCurve request's {what} is not one it serves: {value:?}")]
810 Request {
811 what: &'static str,
813 value: String,
815 },
816 #[error(transparent)]
818 Net(#[from] NetError),
819 #[error("the ThrustCurve answer is not of the expected shape: {0}")]
821 Json(String),
822 #[error("ThrustCurve refused the request: {0}")]
824 Api(String),
825 #[error("the ThrustCurve search says {matches} motors match but returns {found}")]
827 Count {
828 matches: u32,
830 found: usize,
832 },
833 #[error("the ThrustCurve search matches {matches} motors but returns only {found}")]
835 Incomplete {
836 matches: u32,
838 found: usize,
840 },
841 #[error("the ThrustCurve answer's {field} is not usable: {value}")]
843 Field {
844 field: String,
846 value: String,
848 },
849 #[error("asked ThrustCurve for {asked}'s motors, but the answer holds one of {found}'s")]
851 OtherManufacturer {
852 asked: String,
854 found: String,
856 },
857 #[error("asked ThrustCurve for {asked}'s files, but the answer holds {found}'s")]
859 OtherMotor {
860 asked: String,
862 found: String,
864 },
865 #[error(transparent)]
867 Motor(#[from] MotorError),
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 #[test]
875 fn urls_follow_the_apis_names() {
876 assert_eq!(
877 Search::manufacturer("Cesaroni Technology").url(),
878 "https://www.thrustcurve.org/api/v1/search.json?manufacturer=Cesaroni%20Technology&maxResults=5000"
879 );
880 let search = Search {
881 designation: Some("F27R/L".to_owned()),
882 impulse_class: Some("F".to_owned()),
883 ..Search::default()
884 };
885 assert_eq!(
886 search.url(),
887 "https://www.thrustcurve.org/api/v1/search.json?designation=F27R%2FL&impulseClass=F"
888 );
889 assert_eq!(
890 Search::default().url(),
891 "https://www.thrustcurve.org/api/v1/search.json"
892 );
893 let download = Download::new("5f4294d2000231000000044f", Format::Rasp).unwrap();
894 assert_eq!(
895 download.url(),
896 "https://www.thrustcurve.org/api/v1/download.json?motorId=5f4294d2000231000000044f&format=RASP&data=file"
897 );
898 }
899
900 #[test]
901 fn a_motor_id_must_be_24_hex_digits() {
902 for bad in [
903 "",
904 "5f4294d2000231000000044",
905 "5f4294d2000231000000044f0",
906 "5f4294d2000231000000044g",
907 "5f4294d200023100000004&f",
908 ] {
909 let err = Download::new(bad, Format::Rasp).unwrap_err();
910 assert!(
911 matches!(&err, ThrustCurveError::Request { what: "motor id", value } if value == bad),
912 "{bad}: {err}"
913 );
914 }
915 let upper = Download::new("5F4294D2000231000000044F", Format::RockSim).unwrap();
916 assert_eq!(upper.motor_id(), "5f4294d2000231000000044f");
917 assert!(upper.url().contains("motorId=5f4294d2000231000000044f&"));
918 }
919
920 #[test]
921 fn query_values_are_percent_encoded() {
922 assert_eq!(encode("AZaz09-._~"), "AZaz09-._~");
923 assert_eq!(encode("a b/c&d=e?é"), "a%20b%2Fc%26d%3De%3F%C3%A9");
924 }
925}