1use std::ops::RangeInclusive;
10
11use thiserror::Error;
12
13#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct LineSegment {
17 start_line: usize,
18 end_line: usize,
19 value: f64,
20}
21
22impl LineSegment {
23 #[must_use = "Inspect the segment creation result to handle invalid ranges"]
37 pub fn new(start_line: usize, end_line: usize, value: f64) -> Result<Self, SegmentError> {
38 if start_line == 0 || end_line == 0 {
39 return Err(SegmentError::LineNumberMustBeOneBased {
40 start_line,
41 end_line,
42 });
43 }
44
45 if start_line > end_line {
46 return Err(SegmentError::StartAfterEnd {
47 start_line,
48 end_line,
49 });
50 }
51
52 Ok(Self {
53 start_line,
54 end_line,
55 value,
56 })
57 }
58
59 #[must_use]
61 pub const fn start_line(self) -> usize {
62 self.start_line
63 }
64
65 #[must_use]
67 pub const fn end_line(self) -> usize {
68 self.end_line
69 }
70
71 #[must_use]
73 pub const fn value(self) -> f64 {
74 self.value
75 }
76}
77
78#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
80pub enum SegmentError {
81 #[error("line numbers must be one-based (got start_line={start_line}, end_line={end_line})")]
83 LineNumberMustBeOneBased { start_line: usize, end_line: usize },
84
85 #[error(
87 "segment start must not occur after end (start_line={start_line}, end_line={end_line})"
88 )]
89 StartAfterEnd { start_line: usize, end_line: usize },
90}
91
92#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
94pub enum SignalBuildError {
95 #[error(
97 "function line range must be one-based (got start_line={start_line}, end_line={end_line})"
98 )]
99 FunctionLineRangeMustBeOneBased { start_line: usize, end_line: usize },
100
101 #[error(
103 "function start must not occur after end (start_line={start_line}, end_line={end_line})"
104 )]
105 FunctionStartAfterEnd { start_line: usize, end_line: usize },
106
107 #[error(
109 "segment lies outside function range (segment={segment_start}..={segment_end}, function={function_start}..={function_end})"
110 )]
111 SegmentOutsideFunctionRange {
112 segment_start: usize,
113 segment_end: usize,
114 function_start: usize,
115 function_end: usize,
116 },
117}
118
119fn validate_function_range(
120 function_start: usize,
121 function_end: usize,
122) -> Result<(), SignalBuildError> {
123 if function_start == 0 || function_end == 0 {
124 return Err(SignalBuildError::FunctionLineRangeMustBeOneBased {
125 start_line: function_start,
126 end_line: function_end,
127 });
128 }
129
130 if function_start > function_end {
131 return Err(SignalBuildError::FunctionStartAfterEnd {
132 start_line: function_start,
133 end_line: function_end,
134 });
135 }
136
137 Ok(())
138}
139
140fn validate_segment_in_range(
141 segment: &LineSegment,
142 function_start: usize,
143 function_end: usize,
144) -> Result<(), SignalBuildError> {
145 if segment.end_line() < function_start || segment.start_line() > function_end {
146 return Err(SignalBuildError::SegmentOutsideFunctionRange {
147 segment_start: segment.start_line(),
148 segment_end: segment.end_line(),
149 function_start,
150 function_end,
151 });
152 }
153
154 Ok(())
155}
156
157fn apply_segment_to_diff(segment: &LineSegment, diff: &mut [f64], function_start: usize) {
158 let segment_start = segment.start_line().saturating_sub(function_start);
159 let segment_end = segment.end_line().saturating_sub(function_start);
160
161 if let Some(cell) = diff.get_mut(segment_start) {
162 *cell += segment.value();
163 }
164
165 if let Some(cell) = diff.get_mut(segment_end + 1) {
166 *cell -= segment.value();
167 }
168}
169
170fn accumulate_signal_from_diff(diff: &[f64], len: usize) -> Vec<f64> {
171 let mut signal = Vec::with_capacity(len);
172 let mut running = 0.0_f64;
173 for delta in diff.iter().take(len) {
174 running += delta;
175 signal.push(running);
176 }
177 signal
178}
179
180#[must_use = "Inspect the signal build result to handle invalid ranges"]
208pub fn rasterize_signal(
209 function_lines: RangeInclusive<usize>,
210 segments: &[LineSegment],
211) -> Result<Vec<f64>, SignalBuildError> {
212 let function_start = *function_lines.start();
213 let function_end = *function_lines.end();
214
215 validate_function_range(function_start, function_end)?;
216
217 let len = function_end - function_start + 1;
218 let mut diff = vec![0.0_f64; len + 1];
219
220 for segment in segments {
221 validate_segment_in_range(segment, function_start, function_end)?;
222 apply_segment_to_diff(segment, diff.as_mut_slice(), function_start);
223 }
224
225 Ok(accumulate_signal_from_diff(diff.as_slice(), len))
226}
227
228#[deprecated(note = "Use rasterize_signal instead.")]
229#[must_use = "Inspect the signal build result to handle invalid ranges"]
230pub fn rasterise_signal(
231 function_lines: RangeInclusive<usize>,
232 segments: &[LineSegment],
233) -> Result<Vec<f64>, SignalBuildError> {
234 rasterize_signal(function_lines, segments)
235}
236
237#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
239pub enum SmoothingError {
240 #[error("smoothing window must be positive (got {window})")]
242 WindowMustBePositive { window: usize },
243
244 #[error("smoothing window must be odd (got {window})")]
246 WindowMustBeOdd { window: usize },
247}
248
249#[must_use = "Inspect the smoothing result to handle invalid window sizes"]
269pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result<Vec<f64>, SmoothingError> {
270 if window == 0 {
271 return Err(SmoothingError::WindowMustBePositive { window });
272 }
273
274 fn is_even(value: usize) -> bool {
275 (value & 1) == 0
276 }
277
278 if is_even(window) {
279 return Err(SmoothingError::WindowMustBeOdd { window });
280 }
281
282 if signal.is_empty() {
283 return Ok(Vec::new());
284 }
285
286 let half_window = window / 2;
287 let mut prefix = Vec::with_capacity(signal.len() + 1);
288 prefix.push(0.0_f64);
289 for &value in signal {
290 let next = prefix[prefix.len() - 1] + value;
291 prefix.push(next);
292 }
293
294 let last_index = signal.len() - 1;
295 let mut smoothed = Vec::with_capacity(signal.len());
296 for idx in 0..signal.len() {
297 let start = idx.saturating_sub(half_window);
298 let end = (idx + half_window).min(last_index);
299 let sum = prefix[end + 1] - prefix[start];
300 let count = (end - start + 1) as f64;
301 smoothed.push(sum / count);
302 }
303
304 Ok(smoothed)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use rstest::rstest;
311
312 #[rstest]
313 fn rasterize_signal_accumulates_overlapping_segments() {
314 let segments = vec![
315 LineSegment::new(10, 12, 1.0).expect("segment should be valid"),
316 LineSegment::new(12, 14, 2.0).expect("segment should be valid"),
317 ];
318
319 let signal = rasterize_signal(10..=14, &segments).expect("signal should build");
320 assert_eq!(signal, vec![1.0, 1.0, 3.0, 2.0, 2.0]);
321 }
322
323 #[rstest]
324 fn rasterize_signal_rejects_segments_outside_function_range() {
325 let segments = vec![LineSegment::new(9, 10, 1.0).expect("segment should be valid")];
326
327 let err = rasterize_signal(11..=14, &segments).expect_err("segment should be rejected");
328 assert!(matches!(
329 err,
330 SignalBuildError::SegmentOutsideFunctionRange { .. }
331 ));
332 }
333
334 #[rstest]
335 fn moving_average_smoothing_uses_central_window() {
336 let signal = vec![0.0, 0.0, 3.0, 0.0, 0.0];
337 let smoothed = smooth_moving_average(&signal, 3).expect("window should be valid");
338 assert_eq!(smoothed, vec![0.0, 1.0, 1.0, 1.0, 0.0]);
339 }
340
341 #[rstest]
342 fn moving_average_window_must_be_positive() {
343 let err = smooth_moving_average(&[1.0, 2.0], 0).expect_err("window should be rejected");
344 assert_eq!(err, SmoothingError::WindowMustBePositive { window: 0 });
345 }
346
347 #[rstest]
348 fn moving_average_window_must_be_odd() {
349 let err = smooth_moving_average(&[1.0, 2.0], 2).expect_err("window should be rejected");
350 assert_eq!(err, SmoothingError::WindowMustBeOdd { window: 2 });
351 }
352
353 #[rstest]
354 fn segment_validation_rejects_start_after_end() {
355 let err = LineSegment::new(6, 4, 1.0).expect_err("segment should be invalid");
356 assert_eq!(
357 err,
358 SegmentError::StartAfterEnd {
359 start_line: 6,
360 end_line: 4,
361 }
362 );
363 }
364}