fit_kit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Cargo.lock +6 -16
- data/README.md +13 -0
- data/ext/fit_kit/Cargo.toml +2 -3
- data/ext/fit_kit/src/lib.rs +504 -50
- data/lib/fit_kit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 10c537aabfcdc0d4542be231629868eca99e14a6f5ee2cf7a3f313e964f14ddc
|
4
|
+
data.tar.gz: 218b078edf26e38088d3da9565be4243ea83a1d5eb56d8f570e600795bd21a3f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 648647a0900cb94c8828f48935196b3e4395206c4fb08dd122a0c4300f156a0f3a507f0ee9bf141a1456cb243adbefdb881716b42bf1793ce81e334414336e19
|
7
|
+
data.tar.gz: 217bc0fb924c5fe02b444252ac6c4547294bcde9de6ee913459e7fe742efa8d3fce092118272cd8c0559b29f0cbf7733b75b72fe247d01ddd9b0bc37a7a9c72d
|
data/Cargo.lock
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# This file is automatically @generated by Cargo.
|
2
2
|
# It is not intended for manual editing.
|
3
|
-
version =
|
3
|
+
version = 4
|
4
4
|
|
5
5
|
[[package]]
|
6
6
|
name = "aho-corasick"
|
@@ -41,7 +41,7 @@ dependencies = [
|
|
41
41
|
"bitflags",
|
42
42
|
"cexpr",
|
43
43
|
"clang-sys",
|
44
|
-
"itertools
|
44
|
+
"itertools",
|
45
45
|
"lazy_static",
|
46
46
|
"lazycell",
|
47
47
|
"proc-macro2",
|
@@ -131,15 +131,14 @@ name = "fit_kit"
|
|
131
131
|
version = "0.1.0"
|
132
132
|
dependencies = [
|
133
133
|
"fitparser",
|
134
|
-
"itertools 0.13.0",
|
135
134
|
"magnus",
|
136
135
|
]
|
137
136
|
|
138
137
|
[[package]]
|
139
138
|
name = "fitparser"
|
140
|
-
version = "0.
|
139
|
+
version = "0.9.0"
|
141
140
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
142
|
-
checksum = "
|
141
|
+
checksum = "dc8dfc12dd3f7ec76da4609addc6700a3d5c9e37e8c0e924c7fbd61bc52d70a7"
|
143
142
|
dependencies = [
|
144
143
|
"chrono",
|
145
144
|
"nom",
|
@@ -184,15 +183,6 @@ dependencies = [
|
|
184
183
|
"either",
|
185
184
|
]
|
186
185
|
|
187
|
-
[[package]]
|
188
|
-
name = "itertools"
|
189
|
-
version = "0.13.0"
|
190
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
191
|
-
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
192
|
-
dependencies = [
|
193
|
-
"either",
|
194
|
-
]
|
195
|
-
|
196
186
|
[[package]]
|
197
187
|
name = "js-sys"
|
198
188
|
version = "0.3.72"
|
@@ -238,9 +228,9 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
|
238
228
|
|
239
229
|
[[package]]
|
240
230
|
name = "magnus"
|
241
|
-
version = "0.
|
231
|
+
version = "0.7.1"
|
242
232
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
243
|
-
checksum = "
|
233
|
+
checksum = "3d87ae53030f3a22e83879e666cb94e58a7bdf31706878a0ba48752994146dab"
|
244
234
|
dependencies = [
|
245
235
|
"magnus-macros",
|
246
236
|
"rb-sys",
|
data/README.md
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# FitKit
|
2
2
|
|
3
|
+
> Not production ready yet.
|
4
|
+
|
3
5
|
`fit_kit` is a small ruby gem wraps Rust's crate [fitparse-rs](https://github.com/stadelmanma/fitparse-rs) to provide effecient and fast Garmin fit files parsing.
|
4
6
|
|
5
7
|
## Installation
|
@@ -20,6 +22,17 @@ fit_data_records = ::FitKit.parse_fit_file(test_fit_file)
|
|
20
22
|
# { record: [{...}, {...}], session: [{...}], lap: [..], activity: [...] }
|
21
23
|
```
|
22
24
|
|
25
|
+
## Features
|
26
|
+
There are couple of more built-in features can simplify the consuming of the data significantly:
|
27
|
+
|
28
|
+
- `records_hash` - All records in a ruby hash format
|
29
|
+
- `avg_for` - Average of a given field
|
30
|
+
- `elevation_gain` - Elevation gain. Calculate the overall elevation gain. Also allow to pass in a field.
|
31
|
+
- `calculate_partition_indices` - Calculate the partition indices for a given field
|
32
|
+
- `partition_stats_for_fields` - Calculate the partition stats for a given field. Useful for calculating splits data.
|
33
|
+
- `sample_series_for_records` - Sample series for a given field, useful for plotting
|
34
|
+
- `zone_time_for` - Heart Rate Zones data
|
35
|
+
|
23
36
|
## Performance
|
24
37
|
Here is the performance parsing __4090__ fit files on my M1 Mac Mini (16G, 8 Cores) took 6 seconds (in parallel):
|
25
38
|
|
data/ext/fit_kit/Cargo.toml
CHANGED
data/ext/fit_kit/src/lib.rs
CHANGED
@@ -1,55 +1,524 @@
|
|
1
|
-
use fitparser::{self, FitDataRecord, Value};
|
2
|
-
use
|
3
|
-
use
|
4
|
-
|
1
|
+
use fitparser::{self, profile::MesgNum, FitDataField, FitDataRecord, Value};
|
2
|
+
use magnus::{function, method, prelude::*, Error, IntoValue, RArray, RHash, Ruby, Symbol};
|
3
|
+
use std::{
|
4
|
+
collections::{BTreeMap, HashMap},
|
5
|
+
fs::File,
|
6
|
+
};
|
7
|
+
|
8
|
+
/// Extesnion methods for FitDataRecord
|
9
|
+
pub trait FitDataRecordExt {
|
10
|
+
fn timestamp(&self) -> Option<i64>;
|
11
|
+
fn field_value(&self, field_name: &str) -> Option<(Value, String)>;
|
12
|
+
}
|
13
|
+
|
14
|
+
impl FitDataRecordExt for FitDataRecord {
|
15
|
+
fn timestamp(&self) -> Option<i64> {
|
16
|
+
self.fields()
|
17
|
+
.iter()
|
18
|
+
.find(|field| field.name() == "timestamp")
|
19
|
+
.and_then(|field| match field.value() {
|
20
|
+
Value::Timestamp(v) => Some(v.timestamp()),
|
21
|
+
_ => None,
|
22
|
+
})
|
23
|
+
.map(|v| v as i64)
|
24
|
+
}
|
25
|
+
|
26
|
+
fn field_value(&self, field_name: &str) -> Option<(Value, String)> {
|
27
|
+
let field = self
|
28
|
+
.fields()
|
29
|
+
.iter()
|
30
|
+
.find(|field| field.name() == field_name)?;
|
31
|
+
|
32
|
+
Some((field.value().clone(), field.units().to_string()))
|
33
|
+
}
|
34
|
+
}
|
35
|
+
|
36
|
+
/// extension methods for vec of FitDataRecord
|
37
|
+
pub trait FitDataRecordVecExt {
|
38
|
+
fn sample_series_for_records(
|
39
|
+
&self,
|
40
|
+
field_name: String,
|
41
|
+
num_of_points: u16,
|
42
|
+
) -> Vec<(i64, f64, String)>;
|
43
|
+
fn aggregate_field_values(&self, records: Vec<&FitDataRecord>, field: &str) -> (f64, String);
|
44
|
+
}
|
45
|
+
|
46
|
+
impl FitDataRecordVecExt for Vec<FitDataRecord> {
|
47
|
+
fn sample_series_for_records(
|
48
|
+
&self,
|
49
|
+
field_name: String,
|
50
|
+
num_of_points: u16,
|
51
|
+
) -> Vec<(i64, f64, String)> {
|
52
|
+
// if there are no records, return empty vec
|
53
|
+
if self.is_empty() {
|
54
|
+
return vec![];
|
55
|
+
}
|
56
|
+
|
57
|
+
// find the min and max timestamp
|
58
|
+
let min_timestamp = self
|
59
|
+
.iter()
|
60
|
+
.find(|r| r.kind() == MesgNum::Record && r.timestamp().is_some())
|
61
|
+
.and_then(|r| r.timestamp());
|
62
|
+
|
63
|
+
let max_timestamp = self
|
64
|
+
.iter()
|
65
|
+
.rev()
|
66
|
+
.find(|r| r.kind() == MesgNum::Record && r.timestamp().is_some())
|
67
|
+
.and_then(|r| r.timestamp());
|
68
|
+
|
69
|
+
// if both exists and min is less than max, we proceed
|
70
|
+
// otherwise return empty vec
|
71
|
+
let (min, max) = match (min_timestamp, max_timestamp) {
|
72
|
+
(Some(min), Some(max)) if min < max => (min, max),
|
73
|
+
_ => return vec![],
|
74
|
+
};
|
75
|
+
|
76
|
+
print!("min: {:?}, max: {:?}", min, max);
|
77
|
+
|
78
|
+
// calculate the interval we need to sample
|
79
|
+
let total_duration_in_seconds = max - min;
|
80
|
+
let interval = total_duration_in_seconds / num_of_points as i64;
|
81
|
+
|
82
|
+
// now we can group them into buckets
|
83
|
+
let mut sample_data = BTreeMap::new();
|
84
|
+
for record in self.into_iter().filter(|r| r.timestamp().is_some()) {
|
85
|
+
let timestamp = record.timestamp().unwrap() as i64;
|
86
|
+
let bucket = ((timestamp - min) / interval as i64) as u16;
|
87
|
+
// insert it into the bucket
|
88
|
+
sample_data.entry(bucket).or_insert(vec![]).push(record);
|
89
|
+
}
|
90
|
+
|
91
|
+
// now we can sample the data
|
92
|
+
sample_data
|
93
|
+
.into_iter()
|
94
|
+
.map(|(bucket, records)| {
|
95
|
+
let timestamp = min + (bucket as i64 * interval) as i64;
|
96
|
+
let (value, units) = self.aggregate_field_values(records, &field_name);
|
97
|
+
(timestamp, value, units)
|
98
|
+
})
|
99
|
+
.collect()
|
100
|
+
}
|
101
|
+
|
102
|
+
fn aggregate_field_values(&self, records: Vec<&FitDataRecord>, field: &str) -> (f64, String) {
|
103
|
+
// Use the new field method
|
104
|
+
let values: Vec<f64> = records
|
105
|
+
.iter()
|
106
|
+
.filter_map(|record| {
|
107
|
+
record
|
108
|
+
.field_value(field)
|
109
|
+
.and_then(|(v, _)| MyValue(v).as_f64())
|
110
|
+
})
|
111
|
+
.collect();
|
112
|
+
|
113
|
+
let units = records
|
114
|
+
.iter()
|
115
|
+
.find_map(|record| record.field_value(field).map(|(_, u)| u))
|
116
|
+
.unwrap_or_else(|| String::from(""));
|
117
|
+
|
118
|
+
// Calculate average (or use another aggregation method)
|
119
|
+
if values.is_empty() {
|
120
|
+
(0.0, String::from(""))
|
121
|
+
} else {
|
122
|
+
(values.iter().sum::<f64>() / values.len() as f64, units)
|
123
|
+
}
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
127
|
+
// wrap fitparse value
|
128
|
+
struct MyValue(Value);
|
129
|
+
|
130
|
+
impl MyValue {
|
131
|
+
// turn value into f64
|
132
|
+
fn as_f64(&self) -> Option<f64> {
|
133
|
+
match &self.0 {
|
134
|
+
Value::SInt8(i) => Some(*i as f64),
|
135
|
+
Value::UInt8(u) => Some(*u as f64),
|
136
|
+
Value::SInt16(i) => Some(*i as f64),
|
137
|
+
Value::UInt16(u) => Some(*u as f64),
|
138
|
+
Value::SInt32(i) => Some(*i as f64),
|
139
|
+
Value::UInt32(u) => Some(*u as f64),
|
140
|
+
Value::Float32(f) => Some(*f as f64),
|
141
|
+
Value::Float64(f) => Some(*f),
|
142
|
+
Value::UInt8z(u) => Some(*u as f64),
|
143
|
+
Value::UInt16z(u) => Some(*u as f64),
|
144
|
+
Value::UInt32z(u) => Some(*u as f64),
|
145
|
+
Value::SInt64(i) => Some(*i as f64),
|
146
|
+
Value::UInt64(u) => Some(*u as f64),
|
147
|
+
Value::UInt64z(u) => Some(*u as f64),
|
148
|
+
_ => None, // Handle any other variants that don't convert to f64
|
149
|
+
}
|
150
|
+
}
|
151
|
+
}
|
152
|
+
|
153
|
+
#[magnus::wrap(class = "FitParseResult")]
|
154
|
+
struct FitParseResult(Vec<FitDataRecord>);
|
155
|
+
|
156
|
+
impl FitParseResult {
|
157
|
+
/**
|
158
|
+
* Returns Ruby hash for all the records
|
159
|
+
* With keys are the record types
|
160
|
+
*/
|
161
|
+
fn records_hash(&self) -> RHash {
|
162
|
+
let mut records_by_kind_vec: Vec<(String, Vec<&FitDataRecord>)> = {
|
163
|
+
let mut map: HashMap<String, Vec<&FitDataRecord>> = HashMap::new();
|
164
|
+
|
165
|
+
for record in self.0.iter() {
|
166
|
+
let kind = record.kind().to_string();
|
167
|
+
map.entry(kind).or_insert_with(Vec::new).push(record);
|
168
|
+
}
|
169
|
+
|
170
|
+
map.into_iter().collect()
|
171
|
+
};
|
172
|
+
|
173
|
+
// Sort the vector by the first element (kind string)
|
174
|
+
records_by_kind_vec.sort_by(|a, b| a.0.cmp(&b.0));
|
175
|
+
|
176
|
+
// now let's group by the record by kind
|
177
|
+
let result_hash = RHash::new();
|
178
|
+
for (kind, kind_records) in records_by_kind_vec {
|
179
|
+
// turn records into rarray
|
180
|
+
let array = RArray::new();
|
181
|
+
for record in kind_records {
|
182
|
+
// TODO here do not pass RFitDataRecord
|
183
|
+
// turn it into fields_hash directly
|
184
|
+
array.push(get_fields_hash(record)).unwrap();
|
185
|
+
}
|
186
|
+
|
187
|
+
result_hash.aset(Symbol::new(kind), array).unwrap();
|
188
|
+
}
|
189
|
+
|
190
|
+
result_hash
|
191
|
+
}
|
192
|
+
|
193
|
+
// summary methods
|
194
|
+
fn avg_for(&self, field_name: String) -> (f64, String) {
|
195
|
+
// passing the reference
|
196
|
+
self.avg_for_records(&self.0, field_name)
|
197
|
+
}
|
198
|
+
|
199
|
+
fn elevation_gain(&self, field_name: String) -> (f64, String) {
|
200
|
+
self.elevation_gain_for_records(&self.0, field_name)
|
201
|
+
}
|
202
|
+
|
203
|
+
// given a bunch of records, calculate the elevation gain
|
204
|
+
fn elevation_gain_for_records(
|
205
|
+
&self,
|
206
|
+
records: &Vec<FitDataRecord>,
|
207
|
+
field_name: String,
|
208
|
+
) -> (f64, String) {
|
209
|
+
let fields: Vec<&FitDataField> = records
|
210
|
+
.iter()
|
211
|
+
.filter(|r| r.kind() == MesgNum::Record)
|
212
|
+
.flat_map(|r| r.fields().iter().filter(|field| field.name() == field_name))
|
213
|
+
.collect();
|
214
|
+
|
215
|
+
let count = fields.len();
|
216
|
+
|
217
|
+
if count == 0 {
|
218
|
+
return (0.0, String::from(""));
|
219
|
+
}
|
220
|
+
|
221
|
+
let units = fields.first().unwrap().units();
|
222
|
+
|
223
|
+
let elevation_gain_sum = fields.windows(2).fold(0.0, |acc, window| {
|
224
|
+
// find the field first
|
225
|
+
let value1 = MyValue(window[1].value().clone()).as_f64();
|
226
|
+
let value0 = MyValue(window[0].value().clone()).as_f64();
|
227
|
+
|
228
|
+
match (value1, value0) {
|
229
|
+
(Some(v1), Some(v0)) if v1 > v0 => acc + (v1 - v0),
|
230
|
+
_ => acc,
|
231
|
+
}
|
232
|
+
});
|
233
|
+
|
234
|
+
(elevation_gain_sum, String::from(units))
|
235
|
+
}
|
236
|
+
|
237
|
+
fn partition_stats_for_fields(
|
238
|
+
&self,
|
239
|
+
by_field: String,
|
240
|
+
partition_distance: f64,
|
241
|
+
fields: Vec<String>,
|
242
|
+
) -> Vec<Vec<(String, (f64, String))>> {
|
243
|
+
// first get the partitions
|
244
|
+
let partition_indices = self.calculate_partition_indices(partition_distance, by_field);
|
245
|
+
|
246
|
+
partition_indices
|
247
|
+
.windows(2)
|
248
|
+
.map(|window| {
|
249
|
+
let start = window[0];
|
250
|
+
let end = window[1];
|
251
|
+
let partition_records = self.0[start..=end].to_vec();
|
252
|
+
|
253
|
+
// ok we have this, now we can map the resulf the field
|
254
|
+
let results: Vec<(String, (f64, String))> = fields
|
255
|
+
.iter()
|
256
|
+
.map(|field_name| {
|
257
|
+
(
|
258
|
+
field_name.clone(),
|
259
|
+
self.avg_for_records(&partition_records, field_name.clone()),
|
260
|
+
)
|
261
|
+
})
|
262
|
+
.collect();
|
263
|
+
|
264
|
+
results
|
265
|
+
})
|
266
|
+
.collect()
|
267
|
+
}
|
268
|
+
|
269
|
+
fn zone_time_for(&self, zones: Vec<(f64, f64)>, field_name: String) -> Vec<((f64, f64), f64)> {
|
270
|
+
// filter records
|
271
|
+
let filtered_records: Vec<&FitDataRecord> = self
|
272
|
+
.0
|
273
|
+
.iter()
|
274
|
+
.filter(|r| r.kind() == MesgNum::Record)
|
275
|
+
.filter(|r| r.fields().iter().any(|f| f.name() == field_name))
|
276
|
+
.collect();
|
277
|
+
self.zone_time_for_records(filtered_records, zones, field_name)
|
278
|
+
}
|
279
|
+
|
280
|
+
/// Given a list of records, and list of zones, calculate the time spent in each zone
|
281
|
+
fn zone_time_for_records(
|
282
|
+
&self,
|
283
|
+
records: Vec<&FitDataRecord>,
|
284
|
+
zones: Vec<(f64, f64)>,
|
285
|
+
field_name: String,
|
286
|
+
) -> Vec<((f64, f64), f64)> {
|
287
|
+
let mut zone_times: Vec<((f64, f64), f64)> =
|
288
|
+
zones.iter().map(|z| (z.clone(), 0.0)).collect();
|
289
|
+
|
290
|
+
for window in records.windows(2) {
|
291
|
+
let value1 = window[1]
|
292
|
+
.fields()
|
293
|
+
.iter()
|
294
|
+
.find(|f| f.name() == field_name)
|
295
|
+
.unwrap()
|
296
|
+
.value();
|
297
|
+
|
298
|
+
// using MyValue to extract the value
|
299
|
+
let value1 = match MyValue(value1.clone()).as_f64() {
|
300
|
+
Some(v) => v,
|
301
|
+
None => continue,
|
302
|
+
};
|
303
|
+
|
304
|
+
// we need to find the zone from start again
|
305
|
+
// because the value could drop below the current zone
|
306
|
+
// always reset the current zone index to 0
|
307
|
+
let mut current_zone_index = 0;
|
308
|
+
while current_zone_index < zones.len() - 1 && value1 > zones[current_zone_index].1 {
|
309
|
+
// moving to next zone
|
310
|
+
// as current zone index is less than the last zone
|
311
|
+
// also the value is greater than the current zone's max
|
312
|
+
current_zone_index += 1;
|
313
|
+
}
|
314
|
+
|
315
|
+
// find the timestamp value
|
316
|
+
let timestamp1 = window[1]
|
317
|
+
.fields()
|
318
|
+
.iter()
|
319
|
+
.find(|f| f.name() == "timestamp")
|
320
|
+
.unwrap()
|
321
|
+
.value();
|
322
|
+
|
323
|
+
let timestamp1_value = match timestamp1 {
|
324
|
+
Value::Timestamp(t) => t.timestamp(),
|
325
|
+
_ => continue,
|
326
|
+
};
|
327
|
+
|
328
|
+
let timestamp0 = window[0]
|
329
|
+
.fields()
|
330
|
+
.iter()
|
331
|
+
.find(|f| f.name() == "timestamp")
|
332
|
+
.unwrap()
|
333
|
+
.value();
|
334
|
+
|
335
|
+
let timestamp0_value = match timestamp0 {
|
336
|
+
Value::Timestamp(t) => t.timestamp(),
|
337
|
+
_ => continue,
|
338
|
+
};
|
339
|
+
|
340
|
+
let time_diff = timestamp1_value - timestamp0_value;
|
341
|
+
zone_times[current_zone_index].1 += time_diff as f64;
|
342
|
+
}
|
343
|
+
|
344
|
+
zone_times
|
345
|
+
}
|
346
|
+
|
347
|
+
fn sample_series_for_records(
|
348
|
+
&self,
|
349
|
+
field_name: String,
|
350
|
+
num_of_points: u16,
|
351
|
+
) -> Vec<(i64, f64, String)> {
|
352
|
+
self.0.sample_series_for_records(field_name, num_of_points)
|
353
|
+
}
|
354
|
+
|
355
|
+
/// Calculate the average for a given field name for a list of records
|
356
|
+
fn avg_for_records(&self, records: &Vec<FitDataRecord>, field_name: String) -> (f64, String) {
|
357
|
+
// only get the record types
|
358
|
+
let fields: Vec<&FitDataField> = records
|
359
|
+
.iter()
|
360
|
+
.filter(|r| r.kind() == MesgNum::Record)
|
361
|
+
.flat_map(|r| r.fields().iter().filter(|field| field.name() == field_name))
|
362
|
+
.collect();
|
363
|
+
|
364
|
+
// do a map filter to only sum the possible values could be sumed
|
365
|
+
// we only care about int, float values
|
366
|
+
let sumable_values: Vec<f64> = fields
|
367
|
+
.iter()
|
368
|
+
.filter_map(|field| MyValue(field.value().clone()).as_f64())
|
369
|
+
.collect();
|
370
|
+
|
371
|
+
let sum: f64 = sumable_values.iter().sum();
|
372
|
+
let count = sumable_values.len();
|
373
|
+
|
374
|
+
if count == 0 {
|
375
|
+
(0.0, String::from(""))
|
376
|
+
} else {
|
377
|
+
// we also need to return the unit
|
378
|
+
let units = fields.first().unwrap().units();
|
379
|
+
let avg_value = sum / count as f64;
|
380
|
+
(avg_value, String::from(units))
|
381
|
+
}
|
382
|
+
}
|
383
|
+
|
384
|
+
fn calculate_partition_indices(
|
385
|
+
&self,
|
386
|
+
partition_distance: f64,
|
387
|
+
field_name: String,
|
388
|
+
) -> Vec<usize> {
|
389
|
+
let records: Vec<&FitDataRecord> = self
|
390
|
+
.0
|
391
|
+
.iter()
|
392
|
+
.filter(|r| r.kind() == MesgNum::Record)
|
393
|
+
.collect();
|
394
|
+
self.calculate_partition_indices_for_records(records, partition_distance, field_name)
|
395
|
+
}
|
396
|
+
|
397
|
+
/// Given a list of records, calculate the partition indices based on the field name and partition distance
|
398
|
+
fn calculate_partition_indices_for_records(
|
399
|
+
&self,
|
400
|
+
records: Vec<&FitDataRecord>,
|
401
|
+
partition_distance: f64,
|
402
|
+
field_name: String,
|
403
|
+
) -> Vec<usize> {
|
404
|
+
let mut partition_indices = vec![0]; // always start include the start index
|
405
|
+
let mut start_distance = 0.0;
|
406
|
+
|
407
|
+
// let's loop
|
408
|
+
for (index, record) in records.iter().enumerate().skip(1) {
|
409
|
+
let fields: Vec<&FitDataField> = record
|
410
|
+
.fields()
|
411
|
+
.iter()
|
412
|
+
.filter(|f| f.name() == field_name)
|
413
|
+
.collect();
|
414
|
+
|
415
|
+
let distance_field = fields
|
416
|
+
.first()
|
417
|
+
.and_then(|f| MyValue(f.value().clone()).as_f64());
|
418
|
+
match distance_field {
|
419
|
+
Some(distance_value) => {
|
420
|
+
if distance_value - start_distance >= partition_distance {
|
421
|
+
// found it
|
422
|
+
partition_indices.push(index);
|
423
|
+
start_distance = distance_value;
|
424
|
+
}
|
425
|
+
}
|
426
|
+
None => {}
|
427
|
+
}
|
428
|
+
}
|
429
|
+
|
430
|
+
// now we have the whole array
|
431
|
+
// if the last record is not there, add it
|
432
|
+
if *partition_indices.last().unwrap() != records.len() - 1 {
|
433
|
+
partition_indices.push(records.len() - 1);
|
434
|
+
}
|
435
|
+
|
436
|
+
partition_indices
|
437
|
+
}
|
438
|
+
}
|
5
439
|
|
6
440
|
// recursive method to turn Fit value into magnus::Value
|
7
|
-
fn value_to_rb_value(value: &Value) -> magnus::Value {
|
441
|
+
fn value_to_rb_value(value: &Value) -> Option<magnus::Value> {
|
8
442
|
match value {
|
9
|
-
Value::Timestamp(t) => t.timestamp().into_value(),
|
10
|
-
Value::SInt8(i) => i.into_value(),
|
11
|
-
Value::UInt8(u) => u.into_value(),
|
12
|
-
Value::SInt16(i) => i.into_value(),
|
13
|
-
Value::UInt16(u) => u.into_value(),
|
14
|
-
Value::SInt32(i) => i.into_value(),
|
15
|
-
Value::UInt32(u) => u.into_value(),
|
16
|
-
Value::String(s) => s.clone().into_value(),
|
17
|
-
Value::Float32(f) => f.into_value(),
|
18
|
-
Value::Float64(f) => f.into_value(),
|
19
|
-
Value::UInt8z(u) => u.into_value(),
|
20
|
-
Value::UInt16z(u) => u.into_value(),
|
21
|
-
Value::UInt32z(u) => u.into_value(),
|
22
|
-
Value::Byte(b) => b.into_value(),
|
23
|
-
Value::SInt64(i) => i.into_value(),
|
24
|
-
Value::UInt64(u) => u.into_value(),
|
25
|
-
Value::UInt64z(u) => u.into_value(),
|
26
|
-
Value::Enum(e) => e.into_value(),
|
443
|
+
Value::Timestamp(t) => Some(t.timestamp().into_value()),
|
444
|
+
Value::SInt8(i) => Some(i.into_value()),
|
445
|
+
Value::UInt8(u) => Some(u.into_value()),
|
446
|
+
Value::SInt16(i) => Some(i.into_value()),
|
447
|
+
Value::UInt16(u) => Some(u.into_value()),
|
448
|
+
Value::SInt32(i) => Some(i.into_value()),
|
449
|
+
Value::UInt32(u) => Some(u.into_value()),
|
450
|
+
Value::String(s) => Some(s.clone().into_value()),
|
451
|
+
Value::Float32(f) => Some(f.into_value()),
|
452
|
+
Value::Float64(f) => Some(f.into_value()),
|
453
|
+
Value::UInt8z(u) => Some(u.into_value()),
|
454
|
+
Value::UInt16z(u) => Some(u.into_value()),
|
455
|
+
Value::UInt32z(u) => Some(u.into_value()),
|
456
|
+
Value::Byte(b) => Some(b.into_value()),
|
457
|
+
Value::SInt64(i) => Some(i.into_value()),
|
458
|
+
Value::UInt64(u) => Some(u.into_value()),
|
459
|
+
Value::UInt64z(u) => Some(u.into_value()),
|
460
|
+
Value::Enum(e) => Some(e.into_value()),
|
27
461
|
Value::Array(arr) => {
|
28
462
|
let rb_array = RArray::new();
|
29
463
|
for value in arr {
|
30
464
|
rb_array.push(value_to_rb_value(value)).unwrap();
|
31
465
|
}
|
32
|
-
rb_array.into_value()
|
466
|
+
Some(rb_array.into_value())
|
33
467
|
}
|
468
|
+
Value::Invalid => None,
|
34
469
|
}
|
35
470
|
}
|
36
471
|
|
472
|
+
// Turning FitDataRecord into a hash
|
37
473
|
fn get_fields_hash(record: &FitDataRecord) -> RHash {
|
38
474
|
let hash = RHash::new();
|
39
475
|
for field in record.fields() {
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
476
|
+
match value_to_rb_value(field.value()) {
|
477
|
+
Some(value) => {
|
478
|
+
let pair = RHash::new();
|
479
|
+
pair.aset(Symbol::new("units"), field.units()).unwrap();
|
480
|
+
pair.aset(Symbol::new("value"), value).unwrap();
|
481
|
+
// here we add the stuff to the hash
|
482
|
+
let field_name_symbol = Symbol::new(field.name());
|
483
|
+
hash.aset(field_name_symbol, pair).unwrap();
|
484
|
+
}
|
485
|
+
None => {}
|
486
|
+
}
|
47
487
|
}
|
48
488
|
|
49
489
|
hash
|
50
490
|
}
|
51
491
|
|
52
|
-
|
492
|
+
// Here we define two ruby classes
|
493
|
+
// RFitDataRecord and RFitDataField
|
494
|
+
fn define_ruby_classes(ruby: &Ruby) -> Result<(), magnus::Error> {
|
495
|
+
// definie the the other one here
|
496
|
+
let data_record_class = ruby.define_class("FitParseResult", ruby.class_object())?;
|
497
|
+
data_record_class.define_method("records_hash", method!(FitParseResult::records_hash, 0))?;
|
498
|
+
data_record_class.define_method("avg_for", method!(FitParseResult::avg_for, 1))?;
|
499
|
+
data_record_class
|
500
|
+
.define_method("elevation_gain", method!(FitParseResult::elevation_gain, 1))?;
|
501
|
+
data_record_class.define_method(
|
502
|
+
"calculate_partition_indices",
|
503
|
+
method!(FitParseResult::calculate_partition_indices, 2),
|
504
|
+
)?;
|
505
|
+
|
506
|
+
data_record_class.define_method(
|
507
|
+
"partition_stats_for_fields",
|
508
|
+
method!(FitParseResult::partition_stats_for_fields, 3),
|
509
|
+
)?;
|
510
|
+
|
511
|
+
data_record_class.define_method(
|
512
|
+
"sample_series_for_records",
|
513
|
+
method!(FitParseResult::sample_series_for_records, 2),
|
514
|
+
)?;
|
515
|
+
|
516
|
+
data_record_class.define_method("zone_time_for", method!(FitParseResult::zone_time_for, 2))?;
|
517
|
+
|
518
|
+
Ok(())
|
519
|
+
}
|
520
|
+
|
521
|
+
fn parse_fit_file(file_path: String) -> Result<FitParseResult, magnus::Error> {
|
53
522
|
let mut fp = File::open(file_path)
|
54
523
|
.map_err(|e| Error::new(Ruby::get().unwrap().exception_io_error(), e.to_string()))?;
|
55
524
|
let data = fitparser::from_reader(&mut fp).map_err(|e| {
|
@@ -59,31 +528,16 @@ fn parse_fit_file(file_path: String) -> Result<RHash, magnus::Error> {
|
|
59
528
|
)
|
60
529
|
})?;
|
61
530
|
|
62
|
-
|
63
|
-
let result_hash = RHash::new();
|
64
|
-
for (kind, kind_records) in data
|
65
|
-
.iter()
|
66
|
-
.chunk_by(|record| record.kind().to_string())
|
67
|
-
.into_iter()
|
68
|
-
{
|
69
|
-
// turn records into rarray
|
70
|
-
let array = RArray::new();
|
71
|
-
for record in kind_records {
|
72
|
-
// TODO here do not pass RFitDataRecord
|
73
|
-
// turn it into fields_hash directly
|
74
|
-
array.push(get_fields_hash(record)).unwrap();
|
75
|
-
}
|
76
|
-
|
77
|
-
result_hash.aset(Symbol::new(kind), array).unwrap();
|
78
|
-
}
|
531
|
+
let result = FitParseResult(data);
|
79
532
|
|
80
|
-
Ok(
|
533
|
+
Ok(result)
|
81
534
|
}
|
82
535
|
|
83
536
|
#[magnus::init]
|
84
537
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
85
538
|
let module = ruby.define_module("FitKit")?;
|
86
539
|
|
540
|
+
let _ = define_ruby_classes(ruby);
|
87
541
|
module.define_singleton_method("parse_fit_file", function!(parse_fit_file, 1))?;
|
88
542
|
|
89
543
|
Ok(())
|
data/lib/fit_kit/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fit_kit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- 29decibel
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2025-03-17 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: Garmin fit file parser wrapping Rust crate fitparse_rs.
|
14
14
|
email:
|