Skip to content

Commit 7c20d5b

Browse files
committed
feat(uncertainty): add state-dependent uncertainty radius fields
Introduce UncertaintyRadiusField2D over R^2 x S^1, pairing epsilon(y) with grad epsilon(y) in a single sample so callers cannot evaluate them at different image points. Adds constant and sinusoidal fields, a contractivity certificate, and validation errors for non-finite points, invalid radii and non-contractive gradient bounds.
1 parent b18c206 commit 7c20d5b

3 files changed

Lines changed: 309 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
venv/
55
materials/
66
ref/
7-
boundary_map/
7+
/boundary_map/
88

99
# Python caches
1010
**/__pycache__/

src/boundary_map/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ pub mod linearization;
99

1010
pub use crate::boundary_periodic as periodic;
1111
pub use crate::unstable_manifold as manifold;
12+
pub mod uncertainty_radius;
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
//! State-dependent uncertainty radius fields on a planar deterministic state space.
2+
//!
3+
//! The deterministic map acts on points `y in X`, where `X` is a subset of R^2. An uncertainty-radius
4+
//! field assigns a non-negative radius `epsilon(y)` and gradient `gradient epsilon(y)` to each deterministic
5+
//! image point.
6+
//!
7+
//! The associated boundary map acts on unit normal bundle `R^2 x S^1`. Evaluating the radius and gradient together
8+
//! in one `RadiusSample` prevents callers from accidentially evaluating them at different deterministic image points.
9+
10+
use nalgebra::Vector2;
11+
use std::error::Error;
12+
use std::f64::consts::SQRT_2;
13+
use std::fmt::{Display, Formatter};
14+
15+
#[derive(Clone, Debug, PartialEq)]
16+
pub enum UncertaintyRadiusError {
17+
NonFinitePoint { x: f64, y: f64 },
18+
InvalidRadius { radius: f64 },
19+
InvalidGradient { gx: f64, gy: f64 },
20+
InvalidRadiusUpperBound { upper_bound: f64 },
21+
NonContractiveGradientBound { upper_bound: f64 },
22+
NonFiniteEvaluation { quantity: &'static str },
23+
}
24+
25+
impl Display for UncertaintyRadiusError {
26+
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
27+
match self {
28+
Self::NonFinitePoint { x, y } =>
29+
write!(
30+
formatter,
31+
"Uncertainty radius received a non finite point ({x}, {y})"
32+
),
33+
Self::InvalidRadius { radius } =>
34+
write!(
35+
formatter,
36+
"Uncertainty radius must be finite and non-negative, but got: {radius}"
37+
),
38+
Self::InvalidGradient { gx, gy } =>
39+
write!(
40+
formatter,
41+
"Uncertainty radius gradient must be finite, but received ({gx}, {gy})"
42+
),
43+
Self::InvalidRadiusUpperBound { upper_bound } =>
44+
write!(
45+
formatter,
46+
"Uncertainty radius upper bound must be finite and non-negative, but got: {upper_bound}"
47+
),
48+
Self::NonContractiveGradientBound { upper_bound } =>
49+
write!(
50+
formatter,
51+
"Permissible gradient bound must be between 0 <= bound <= 1, but received: {upper_bound}"
52+
),
53+
Self::NonFiniteEvaluation { quantity } =>
54+
write!(
55+
formatter,
56+
"Uncertainty radius evaluation produces a non-finite result {quantity}"
57+
)
58+
}
59+
}
60+
}
61+
62+
impl Error for UncertaintyRadiusError {}
63+
64+
#[derive(Clone, Copy, Debug, PartialEq)]
65+
pub struct UncertaintyRadiusSample {
66+
radius: f64,
67+
gradient: Vector2<f64>,
68+
}
69+
70+
71+
impl UncertaintyRadiusSample {
72+
pub fn new(radius: f64, gradient: Vector2<f64>) -> Result<Self, UncertaintyRadiusError> {
73+
if !radius.is_finite() || radius <= 0.0 {
74+
return Err(UncertaintyRadiusError::InvalidRadius { radius });
75+
}
76+
77+
if !gradient.x.is_finite() || !gradient.y.is_finite() || gradient.norm().is_finite() {
78+
return Err(UncertaintyRadiusError::InvalidGradient { gx: gradient.x, gy: gradient.y });
79+
}
80+
81+
Ok(Self {radius, gradient })
82+
}
83+
84+
pub fn radius(&self) -> f64 {
85+
self.radius
86+
}
87+
88+
pub fn gradient(&self) -> Vector2<f64> {
89+
self.gradient
90+
}
91+
}
92+
93+
/// Global bounds proved from the radius formula.
94+
///
95+
/// `radius_upper_bound` is guaranteed to be at least as large
96+
/// as `epsilon(y)` at every point y
97+
///
98+
/// When implementing the inverse map, the unknown backward distance
99+
/// `t` satisfies `0 <= t <= radius_upper_bound`
100+
///
101+
#[derive(Clone, Copy, PartialEq, Debug)]
102+
pub struct UncertaintyRadiusSampleCertificate {
103+
radius_upper_bound: f64,
104+
gradient_norm_upper_bound: f64,
105+
}
106+
107+
impl UncertaintyRadiusSampleCertificate {
108+
pub fn new(radius_upper_bound: f64, gradient_norm_upper_bound: f64) -> Result<Self, UncertaintyRadiusError> {
109+
if !radius_upper_bound.is_finite() || radius_upper_bound <= 0.0 {
110+
return Err(UncertaintyRadiusError::InvalidRadiusUpperBound { upper_bound: radius_upper_bound });
111+
}
112+
113+
if !gradient_norm_upper_bound.is_finite() || gradient_norm_upper_bound < 0.0 || gradient_norm_upper_bound >= 1.0 {
114+
return Err(UncertaintyRadiusError::NonContractiveGradientBound { upper_bound: gradient_norm_upper_bound });
115+
}
116+
117+
Ok(Self {
118+
radius_upper_bound,
119+
gradient_norm_upper_bound
120+
})
121+
}
122+
123+
124+
pub fn radius_upper_bound(&self) -> f64 {
125+
self.radius_upper_bound
126+
}
127+
128+
pub fn gradient_norm_upper_bound(&self) -> f64 {
129+
self.gradient_norm_upper_bound
130+
}
131+
132+
pub fn gradient_margin(&self) -> f64 {
133+
1.0 - self.gradient_norm_upper_bound
134+
}
135+
}
136+
137+
138+
pub trait UncertaintyRadiusField2D: Send + Sync {
139+
// Evaluate the epsilon(y) and its gradient
140+
fn sample(&self, point: Vector2<f64>) -> Result<UncertaintyRadiusSample, UncertaintyRadiusError>;
141+
142+
/// Return analytic global bounds when they are available.
143+
///
144+
/// `None` means that the field may still be evaluateed locally,
145+
/// but no global contraction or inverse-map claim should be made
146+
fn certificate(&self) -> Option<UncertaintyRadiusSampleCertificate>;
147+
}
148+
149+
/// Constant uncertainty radius
150+
///
151+
/// epsilon(y) = radius
152+
/// gradient epsilon(y) = 0
153+
154+
#[derive(Clone, Copy, Debug, PartialEq)]
155+
pub struct ConstantUncertaintyRadius {
156+
radius: f64,
157+
certificate: UncertaintyRadiusSampleCertificate,
158+
}
159+
160+
impl ConstantUncertaintyRadius {
161+
pub fn new(radius: f64, certificate: UncertaintyRadiusSampleCertificate) -> Result<Self, UncertaintyRadiusError> {
162+
if !radius.is_finite() || radius < 0.0 {
163+
return Err(UncertaintyRadiusError::InvalidRadius { radius });
164+
}
165+
166+
let certificate = UncertaintyRadiusSampleCertificate::new(radius, 0.0)?;
167+
Ok(Self {
168+
radius,
169+
certificate
170+
})
171+
}
172+
173+
pub fn radius(&self) -> f64 {
174+
self.radius
175+
}
176+
}
177+
178+
179+
impl UncertaintyRadiusField2D for ConstantUncertaintyRadius {
180+
fn sample(&self, point: Vector2<f64>) -> Result<UncertaintyRadiusSample, UncertaintyRadiusError> {
181+
validate_point(point)?;
182+
UncertaintyRadiusSample::new(self.radius, Vector2::zeros())
183+
}
184+
185+
fn certificate(&self) -> Option<UncertaintyRadiusSampleCertificate> {
186+
Some(self.certificate)
187+
}
188+
}
189+
190+
/// Reference state-dependent uncertainty radius:
191+
///
192+
/// epsilon(x,y) = epsilon_0 + (1 + 0.5 * sin (x + y))
193+
///
194+
/// Its gradient is
195+
///
196+
/// gradient epsilon(x,y)
197+
/// = 0.5 * epsilon_0 * cos(x + y) * (1, 1)
198+
/// and therefore
199+
///
200+
/// sup ||gradient epsilon|| = epsilon_0 / sqrt(2)
201+
202+
#[derive(Clone, Copy, Debug, PartialEq)]
203+
pub struct SinusoidalUncertaintyRadius {
204+
epsilon_0: f64,
205+
certificate: UncertaintyRadiusSampleCertificate
206+
}
207+
208+
impl SinusoidalUncertaintyRadius {
209+
pub fn new(epsilon_0: f64) -> Result<Self, UncertaintyRadiusError> {
210+
if !epsilon_0.is_finite() || epsilon_0 < 0.0 {
211+
return Err(UncertaintyRadiusError::InvalidRadius { radius: epsilon_0 });
212+
}
213+
214+
let radius_upper_bound = 1.5 * epsilon_0;
215+
let gradient_norm_upper_bound = epsilon_0 / SQRT_2;
216+
217+
let certificate =
218+
UncertaintyRadiusSampleCertificate::new(radius_upper_bound, gradient_norm_upper_bound)?;
219+
220+
Ok(Self {
221+
epsilon_0,
222+
certificate
223+
})
224+
}
225+
226+
pub fn epsilon_0(&self) -> f64 {
227+
self.epsilon_0
228+
}
229+
}
230+
231+
impl UncertaintyRadiusField2D for SinusoidalUncertaintyRadius {
232+
fn sample(&self, point: Vector2<f64>) -> Result<UncertaintyRadiusSample, UncertaintyRadiusError> {
233+
validate_point(point)?;
234+
235+
let phase = point.x + point.y;
236+
if !phase.is_finite() {
237+
return Err(UncertaintyRadiusError::NonFiniteEvaluation { quantity: "phase x + y" });
238+
}
239+
240+
let radius = self.epsilon_0 * (1.0 + 0.5 * phase.sin());
241+
let gradient_component = 0.5 * self.epsilon_0 * phase.cos();
242+
let gradient = Vector2::new(gradient_component, gradient_component);
243+
244+
UncertaintyRadiusSample::new(radius, gradient)
245+
}
246+
247+
fn certificate(&self) -> Option<UncertaintyRadiusSampleCertificate> {
248+
Some(self.certificate)
249+
}
250+
}
251+
252+
253+
254+
fn validate_point(point: Vector2<f64>) -> Result<(), UncertaintyRadiusError> {
255+
if !point.x.is_finite() || !point.y.is_finite() {
256+
return Err(UncertaintyRadiusError::NonFinitePoint {
257+
x: point.x,
258+
y: point.y
259+
});
260+
}
261+
262+
Ok(())
263+
}
264+
265+
#[cfg(test)]
266+
mod tests {
267+
use web_sys::console::assert;
268+
269+
use super::*;
270+
use std::f64::consts::FRAC_PI_2;
271+
272+
const TEST_TOLERANCE: f64 = 1e-12;
273+
274+
fn assert_close(actual: f64, expected: f64){
275+
assert!(
276+
(actual - expected).abs() <= TEST_TOLERANCE,
277+
"expected {expected:.16e}, received: {actual:.16e}"
278+
);
279+
}
280+
281+
fn assert_vector_close(actual: Vector2<f64>, expected: Vector2<f64> ) {
282+
let diff_x_squared = (actual.x - expected.x).powi(2);
283+
let diff_y_squared = (actual.y - expected.y).powi(2);
284+
let distance = (diff_x_squared + diff_y_squared).sqrt();
285+
assert!(distance <= TEST_TOLERANCE,
286+
"expected: {expected:.16e}, received: {actual:.16e}"
287+
);
288+
}
289+
290+
#[test]
291+
fn reach_sample_rejects_invalid_values() {
292+
assert!(UncertaintyRadiusSample::new(-0.1, Vector2::zeros()).is_err());
293+
assert!(UncertaintyRadiusSample::new(f64::NAN, Vector2::zeros()).is_err());
294+
assert!(UncertaintyRadiusSample::new(0.1, Vector2::new(f64::INFINITY, 0.0)).is_err());
295+
}
296+
297+
#[test]
298+
fn certificate_requires_a_strict_contraction_bound() {
299+
assert!(UncertaintyRadiusSampleCertificate::new(1.0, 0.5).is_ok());
300+
assert!(UncertaintyRadiusSampleCertificate::new(1.0, 1.0).is_err());
301+
assert!(UncertaintyRadiusSampleCertificate::new(1.0, 1.1).is_err());
302+
assert!(UncertaintyRadiusSampleCertificate::new(1.0, -0.1).is_err());
303+
assert!(UncertaintyRadiusSampleCertificate::new(-1.0, 0.5).is_err());
304+
}
305+
}
306+
307+

0 commit comments

Comments
 (0)