deep_time/dt/to_bin_ccsds.rs
1use crate::{
2 ATTOS_PER_MS_I128, ATTOS_PER_PS_I128, ATTOS_PER_SEC_U128, ATTOS_PER_US_I128, Dt, DtErr,
3 DtErrKind, Scale, an_err,
4};
5
6impl Dt {
7 /// Maximum size needed for a CCSDS **CUC or CDS** binary packet (with extended P-field).
8 ///
9 /// Sized for the largest supported CUC layout (2-byte P-field + 7 coarse + 10 fine = 19 octets)
10 /// with headroom; also sufficient for CDS (≤ 2 + 3 day + 4 ms + 4 sub-ms = 13 octets).
11 pub const CCSDS_C_AND_D_MAX_SIZE: usize = 32;
12
13 /// Formats this [`Dt`] as a **CCSDS C (CUC – Unsegmented Time Code)** binary packet.
14 ///
15 /// Fully configurable for round-tripping with
16 /// [`Dt::from_ccsds_cuc`](../struct.Dt.html#method.from_ccsds_cuc).
17 /// Conforms to **CCSDS 301.0-B-4 §3.2 (Level 1)**, including full support for the
18 /// extended 2-byte P-field.
19 ///
20 /// - The time is always encoded on the **TAI** timescale (Code ID `001`).
21 /// - The `target` field of this [`Dt`] is ignored.
22 ///
23 /// ## Parameters
24 ///
25 /// - `n_coarse`: Number of bytes used for the coarse (integer) seconds since the
26 /// 1958 epoch. Must be in `1..=7`. The value must be large enough to hold the
27 /// full second count; if the instant does not fit, encoding returns
28 /// [`DtErrKind::OutOfRange`] (no silent truncation). For example, a date in
29 /// 2025 requires at least 4 bytes (`n_coarse >= 4`); 3 bytes only reach
30 /// roughly mid-1968.
31 /// - `n_frac`: Number of bytes used for the fractional seconds. Must be in `0..=10`.
32 /// The fine field is the **binary fraction** of a second
33 /// (`floor(frac × 2^(8·n_frac))`), matching a free-running binary counter
34 /// (CCSDS 301.0-B-4 §3.2). Values are **truncated**, never rounded.
35 /// - `extension`: If `true`, forces inclusion of the second P-field octet even
36 /// when it is not strictly required by the field sizes.
37 ///
38 /// ## Epoch
39 ///
40 /// Seconds are counted since **1958-01-01 00:00:00 TAI**.
41 ///
42 /// ## Returns
43 ///
44 /// `(buffer, len)` where `buffer` is a fixed-size array of length
45 /// [`Dt::CCSDS_C_AND_D_MAX_SIZE`](../struct.Dt.html#associatedconstant.CCSDS_C_AND_D_MAX_SIZE)
46 /// and `len` is the number of bytes written.
47 ///
48 /// ## Errors
49 ///
50 /// - [`DtErrKind::OutOfRange`] if `n_coarse` is not in `1..=7`, or if the
51 /// coarse second count does not fit in `n_coarse` octets.
52 /// - [`DtErrKind::FracOutOfRange`] if `n_frac > 10`.
53 /// - [`DtErrKind::YearOutOfRange`] if this instant is before
54 /// **1958-01-01 00:00:00 TAI** (the CUC epoch).
55 ///
56 /// ## See also
57 ///
58 /// - [`Dt::from_ccsds_cuc`](../struct.Dt.html#method.from_ccsds_cuc)
59 pub fn to_ccsds_cuc(
60 &self,
61 n_coarse: u8,
62 n_frac: u8,
63 extension: bool,
64 ) -> Result<([u8; Self::CCSDS_C_AND_D_MAX_SIZE], usize), DtErr> {
65 if !(1..=7).contains(&n_coarse) {
66 return Err(an_err!(DtErrKind::OutOfRange));
67 } else if n_frac > 10 {
68 return Err(an_err!(DtErrKind::FracOutOfRange));
69 }
70
71 let tai_since_1958 = self
72 .target(Scale::TAI)
73 .to_scale_and_diff(Self::CCSDS_EPOCH, false);
74
75 let rem_attos = tai_since_1958.to_sec_ufrac();
76 let total_tai_seconds = tai_since_1958.to_sec64_floor();
77
78 if total_tai_seconds < 0 {
79 return Err(an_err!(DtErrKind::YearOutOfRange, "<1958"));
80 }
81
82 let coarse = total_tai_seconds as u64;
83 // Refuse silent truncation: coarse field must hold the full second count.
84 let coarse_bits = 8u32 * u32::from(n_coarse);
85 let max_coarse = if coarse_bits >= 64 {
86 u64::MAX
87 } else {
88 (1u64 << coarse_bits) - 1
89 };
90 if coarse > max_coarse {
91 return Err(an_err!(DtErrKind::OutOfRange, "n_coarse"));
92 }
93
94 // Binary-fraction fine time: state of a free-running counter
95 // (CCSDS 301.0-B-4 §3.2). Pure truncation — never round.
96 //
97 // Build big-endian octets via successive ×256 / 10¹⁸ so that large
98 // `n_frac` never needs an intermediate wider than u128.
99 let mut frac_bytes = [0u8; 10];
100 if n_frac > 0 {
101 let mut rem = rem_attos as u128;
102 for byte in frac_bytes.iter_mut().take(n_frac as usize) {
103 rem *= 256;
104 *byte = (rem / ATTOS_PER_SEC_U128) as u8;
105 rem %= ATTOS_PER_SEC_U128;
106 }
107 }
108
109 let mut buf = [0u8; Self::CCSDS_C_AND_D_MAX_SIZE];
110 let mut pos = 0usize;
111
112 // Decide whether we need the extended (2-byte) P-field.
113 // Required when coarse > 4 octets, fractional > 3 octets, or caller forces it.
114 let needs_extension = n_coarse > 4 || n_frac > 3 || extension;
115
116 // Base values that fit in the first P-field octet.
117 // Coarse: 2 bits (0-3) → actual octets = base + 1
118 // Fractional: 2 bits (0-3)
119 let base_coarse = if n_coarse <= 4 { n_coarse - 1 } else { 3 };
120 let base_frac = if n_frac <= 3 { n_frac } else { 3 };
121
122 // Build P-field octet 1
123 // Bit 7 = extension flag
124 // Bits 6-4 = Code ID (001 for CUC Level 1)
125 // Bits 3-2 = base coarse (octets - 1)
126 // Bits 1-0 = base fractional octets
127 let mut p1 = 0b0001_0000u8; // Code ID = 001
128 p1 |= (base_coarse << 2) & 0b0000_1100;
129 p1 |= base_frac & 0b0000_0011;
130 if needs_extension {
131 p1 |= 0b1000_0000;
132 }
133 buf[pos] = p1;
134 pos += 1;
135
136 if needs_extension {
137 // Build P-field octet 2 (extended P-field)
138 // Bit 7 = further extension (must be 0)
139 // Bits 6-5 = additional coarse octets (0-3)
140 // Bits 4-2 = additional fractional octets (0-7)
141 // Bits 1-0 = reserved
142 let add_coarse = n_coarse - 4;
143 let add_frac = n_frac.saturating_sub(3);
144
145 let mut p2 = 0u8;
146 p2 |= (add_coarse & 0b11) << 5;
147 p2 |= (add_frac & 0b111) << 2;
148 buf[pos] = p2;
149 pos += 1;
150 }
151
152 // Write coarse time (big-endian)
153 for i in (0..n_coarse).rev() {
154 buf[pos] = (coarse >> (i as u32 * 8)) as u8;
155 pos += 1;
156 }
157
158 // Write fractional time (big-endian, MSB already in frac_bytes[0])
159 for &byte in frac_bytes.iter().take(n_frac as usize) {
160 buf[pos] = byte;
161 pos += 1;
162 }
163
164 Ok((buf, pos))
165 }
166
167 /// Formats this [`Dt`] as a **CCSDS D (CDS – Day Segmented Time Code)** binary packet.
168 ///
169 /// Fully configurable for round-tripping with
170 /// [`Dt::from_ccsds_cds`](../struct.Dt.html#method.from_ccsds_cds).
171 /// Conforms to **CCSDS 301.0-B-4 §3.3 (Level 1)**.
172 ///
173 /// The time is always encoded on the **UTC** timescale (day count + milliseconds
174 /// of day, with optional sub-millisecond segment). Leap-second handling follows
175 /// civil UTC (`second == 60` maps into the 86_400_000 ms range of Annex A).
176 ///
177 /// ## Parameters
178 ///
179 /// - `n_day`: Number of day-count octets. Must be `2` or `3`.
180 /// - `sub_ms_code`: Sub-millisecond resolution (cascaded unit counters):
181 /// - `0`: none (millisecond resolution only)
182 /// - `1`: 2 bytes — **microsecond-of-millisecond**, range **0–999** (Annex A)
183 /// - `2`: 4 bytes — **picosecond-of-millisecond**, range **0–999_999_999**
184 /// - `extension`: If `true`, emits the second P-field octet.
185 ///
186 /// ## Segment counters (truncation)
187 ///
188 /// Each segment is a right-adjusted binary counter (CCSDS 301.0-B-4 §3.3).
189 /// Sub-second fields are **truncated** to the segment unit — never rounded —
190 /// so cascaded segments do not double-count residual time.
191 ///
192 /// ## Epoch
193 ///
194 /// Day count is calendar days since **1958-01-01 00:00:00 UTC**.
195 ///
196 /// ## Returns
197 ///
198 /// `(buffer, len)` where `buffer` is a fixed-size array of length
199 /// [`Dt::CCSDS_C_AND_D_MAX_SIZE`](../struct.Dt.html#associatedconstant.CCSDS_C_AND_D_MAX_SIZE)
200 /// and `len` is the number of bytes written.
201 ///
202 /// ## Errors
203 ///
204 /// - [`DtErrKind::InvalidNumber`] if `n_day` is not `2` or `3`.
205 /// - [`DtErrKind::InvalidSubmillisecond`] if `sub_ms_code` is not in `0..=2`.
206 /// - [`DtErrKind::YearOutOfRange`] if this instant is before
207 /// **1958-01-01 00:00:00 UTC** (the CDS Level 1 epoch).
208 /// - [`DtErrKind::OutOfRange`] if the day count does not fit in `n_day` octets.
209 ///
210 /// ## See also
211 ///
212 /// - [`Dt::from_ccsds_cds`](../struct.Dt.html#method.from_ccsds_cds)
213 pub fn to_ccsds_cds(
214 &self,
215 n_day: u8,
216 sub_ms_code: u8,
217 extension: bool,
218 ) -> Result<([u8; Self::CCSDS_C_AND_D_MAX_SIZE], usize), DtErr> {
219 if !matches!(n_day, 2 | 3) {
220 return Err(an_err!(DtErrKind::InvalidNumber, "n_day: {}", n_day));
221 } else if !matches!(sub_ms_code, 0..=2) {
222 return Err(an_err!(DtErrKind::InvalidSubmillisecond));
223 }
224
225 // Civil UTC so leap seconds map to Annex A ms-of-day ranges.
226 let ymd = self.target(Scale::UTC).to_ymd();
227
228 let day_count_i = Self::ymd_to_days_since_1958(ymd.yr, ymd.mo, ymd.day);
229 if day_count_i < 0 {
230 return Err(an_err!(DtErrKind::YearOutOfRange, "<1958"));
231 }
232 let day_count = day_count_i as u64;
233
234 let max_day = if n_day == 2 {
235 0xFFFF_u64
236 } else {
237 0xFF_FFFF_u64
238 };
239 if day_count > max_day {
240 return Err(an_err!(DtErrKind::OutOfRange, "day count"));
241 }
242
243 // Cascaded counters, pure truncation (no rounding between segments).
244 let attos = ymd.attos as u128;
245 let sec_of_day = u64::from(ymd.hr) * 3600 + u64::from(ymd.min) * 60 + u64::from(ymd.sec);
246
247 let ms_in_sec = (attos / ATTOS_PER_MS_I128 as u128) as u64; // 0..999
248 // Civil fields already bound sec_of_day (≤ 86400 on leap second) and ms_in_sec.
249 let millis_of_day = sec_of_day
250 .checked_mul(1000)
251 .and_then(|v| v.checked_add(ms_in_sec))
252 .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "ms of day"))?;
253
254 let attos_in_ms = attos % ATTOS_PER_MS_I128 as u128;
255
256 let frac_scaled = match sub_ms_code {
257 0 => 0u64,
258 // Microsecond-of-millisecond: Annex A range 0..=999
259 1 => (attos_in_ms / ATTOS_PER_US_I128 as u128) as u64,
260 // Picosecond-of-millisecond: 0..=999_999_999
261 2 => (attos_in_ms / ATTOS_PER_PS_I128 as u128) as u64,
262 // sub_ms_code validated above
263 _ => 0u64,
264 };
265
266 let mut buf = [0u8; Self::CCSDS_C_AND_D_MAX_SIZE];
267 let mut pos = 0usize;
268
269 let mut p1 = 0b0100_0000u8;
270 if extension {
271 p1 |= 0b1000_0000;
272 }
273 if n_day == 3 {
274 p1 |= 0b0000_0100;
275 }
276 p1 |= sub_ms_code;
277 buf[pos] = p1;
278 pos += 1;
279
280 if extension {
281 buf[pos] = 0;
282 pos += 1;
283 }
284
285 for i in (0..n_day).rev() {
286 buf[pos] = (day_count >> (i * 8)) as u8;
287 pos += 1;
288 }
289
290 for i in (0..4).rev() {
291 buf[pos] = (millis_of_day >> (i * 8)) as u8;
292 pos += 1;
293 }
294
295 let n_frac = match sub_ms_code {
296 0 => 0,
297 1 => 2,
298 2 => 4,
299 _ => 0,
300 };
301 for i in (0..n_frac).rev() {
302 buf[pos] = (frac_scaled >> (i * 8)) as u8;
303 pos += 1;
304 }
305
306 Ok((buf, pos))
307 }
308
309 /// Maximum size needed for a CCSDS CCS binary packet (P-field + T-field).
310 pub const CCSDS_CCS_MAX_SIZE: usize = 14; // 1 + 2(year) + 2(date) + 3(HMS) + 6(subsec)
311
312 /// Formats this [`Dt`] as a **CCSDS CCS (Calendar Segmented Time Code)** binary packet.
313 ///
314 /// Fully configurable for round-tripping with
315 /// [`Dt::from_ccsds_ccs`](../struct.Dt.html#method.from_ccsds_ccs).
316 /// Conforms to **CCSDS 301.0-B-4 §3.4** (Level 1 only).
317 ///
318 /// Both CCS variants are **UTC-based** and use BCD encoding.
319 /// Leap seconds are supported (`second = 60`).
320 ///
321 /// ## Parameters
322 ///
323 /// - `use_doy`: `false` = Month/Day variant (most common), `true` = Day-of-Year variant.
324 /// - `n_subsec`: Number of subsecond BCD octets (`0`–`6`). Each octet holds two decimal digits
325 /// (so `n_subsec = 6` gives up to 12 decimal digits of subsecond precision).
326 ///
327 /// ## Subsecond digits
328 ///
329 /// Fractional seconds are **truncated** to the requested number of decimal digits
330 /// (no rounding, no carry into the seconds field). This matches the cascaded-segment
331 /// model used for CDS and avoids second-boundary overflow when the discarded
332 /// residual would have rounded up.
333 ///
334 /// ## Year Range
335 ///
336 /// The year must be in the range **1 to 9999** (as defined by the CCSDS standard).
337 ///
338 /// ## Returns
339 ///
340 /// `(buffer, len)` where `buffer` is a fixed-size array of length
341 /// [`Dt::CCSDS_CCS_MAX_SIZE`](../struct.Dt.html#associatedconstant.CCSDS_CCS_MAX_SIZE)
342 /// and `len` is the number of bytes written.
343 ///
344 /// ## Errors
345 ///
346 /// - [`DtErrKind::FracOutOfRange`] if `n_subsec > 6`.
347 /// - [`DtErrKind::YearOutOfRange`] if the year is outside `1..=9999`.
348 ///
349 /// ## See also
350 ///
351 /// - [`Dt::from_ccsds_ccs`](../struct.Dt.html#method.from_ccsds_ccs)
352 pub fn to_ccsds_ccs(
353 &self,
354 use_doy: bool,
355 n_subsec: u8,
356 ) -> Result<([u8; Self::CCSDS_CCS_MAX_SIZE], usize), DtErr> {
357 if n_subsec > 6 {
358 return Err(an_err!(DtErrKind::FracOutOfRange));
359 }
360
361 // ── Convert to UTC civil time ─────────────────────────────────────────
362 let ymd = self.target(Scale::UTC).to_ymd();
363
364 let year = ymd.yr;
365 if !(1..=9999).contains(&year) {
366 return Err(an_err!(DtErrKind::YearOutOfRange));
367 }
368
369 let mut buf = [0u8; Self::CCSDS_CCS_MAX_SIZE];
370 let mut pos = 0usize;
371
372 // ── P-field (exactly 1 byte, no extension) ─────────────────────────────────────
373 let mut p1 = 0b0101_0000u8; // bits 6-4 = 101 (Code ID)
374 if use_doy {
375 p1 |= 0b0000_1000; // bit 3 = 1 for DOY
376 }
377 p1 |= n_subsec & 0b0000_0111; // bits 2-0 = subsecond count
378 buf[pos] = p1;
379 pos += 1;
380
381 // ── BCD encoder helper (2 decimal digits per byte) ─────────────────────────────
382 let bcd = |val: u32| -> u8 {
383 let hi = (val / 10) as u8;
384 let lo = (val % 10) as u8;
385 (hi << 4) | lo
386 };
387
388 // ── Year (4 BCD digits) ───────────────────────────────────────────────────────
389 let year = year as u32;
390 let y_hi = year / 100;
391 let y_lo = year % 100;
392 buf[pos] = bcd(y_hi);
393 buf[pos + 1] = bcd(y_lo);
394 pos += 2;
395
396 // ── Date field (Month+Day or Day-of-Year) ─────────────────────────────────────
397 if !use_doy {
398 // Month/Day variant
399 buf[pos] = bcd(ymd.mo as u32);
400 buf[pos + 1] = bcd(ymd.day as u32);
401 } else {
402 // Day-of-Year variant
403 let doy = ymd.day_of_yr() as u32;
404 buf[pos] = bcd(doy / 100);
405 buf[pos + 1] = bcd(doy % 100);
406 }
407 pos += 2;
408
409 // ── Hour / Minute / Second (BCD) ──────────────────────────────────────────────
410 buf[pos] = bcd(ymd.hr as u32);
411 buf[pos + 1] = bcd(ymd.min as u32);
412 buf[pos + 2] = bcd(ymd.sec as u32); // leap second 60 is allowed by spec
413 pos += 3;
414
415 // ── Subsecond BCD (0–12 decimal digits, 2 per byte, truncated) ─────────────────
416 if n_subsec > 0 {
417 let decimal_places = (2 * n_subsec) as u32;
418 let scale = 10u128.pow(decimal_places);
419
420 // Truncate attos to the requested decimal resolution (no rounding/carry).
421 let frac_scaled = (ymd.attos as u128 * scale) / ATTOS_PER_SEC_U128;
422
423 let mut remaining = frac_scaled;
424 for i in (0..n_subsec).rev() {
425 let pair = (remaining % 100) as u32;
426 remaining /= 100;
427 buf[pos + i as usize] = bcd(pair);
428 }
429 pos += n_subsec as usize;
430 }
431
432 Ok((buf, pos))
433 }
434
435 /// Convenience method that picks a default CCSDS binary time code from this
436 /// [`Dt`]'s **`target`** [`Scale`].
437 ///
438 /// | Condition | Code | Defaults |
439 /// |-----------|------|----------|
440 /// | `target.uses_leap_seconds()` (UTC, UtcSpice, UtcHist) | **CDS** | 2-byte day, µs-of-ms (`sub_ms_code = 1`), no P-field extension |
441 /// | otherwise (TAI, TT, GPS, …) | **CUC** | 4 coarse + 4 fine octets, no forced extension |
442 ///
443 /// For full control over field widths, call
444 /// [`Dt::to_ccsds_cds`](../struct.Dt.html#method.to_ccsds_cds) or
445 /// [`Dt::to_ccsds_cuc`](../struct.Dt.html#method.to_ccsds_cuc) directly.
446 #[inline(always)]
447 pub fn to_ccsds_bin(&self) -> Result<([u8; Self::CCSDS_C_AND_D_MAX_SIZE], usize), DtErr> {
448 if self.target.uses_leap_seconds() {
449 self.to_ccsds_cds(2, 1, false)
450 } else {
451 self.to_ccsds_cuc(4, 4, false)
452 }
453 }
454}