Skip to content

Commit 602cffd

Browse files
feat: interp1d linear interpolant (#141)
* feat: interp1d linear interpolant * fix ci * another fix to ci * fix: tests * clippy
1 parent e943110 commit 602cffd

12 files changed

Lines changed: 1405 additions & 23 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ jobs:
180180
- name: Setup tmate session
181181
uses: mxschmitt/action-tmate@v3
182182
if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
183+
- name: Workaround missing libcurses.tbd on macOS
184+
if : matrix.os == 'macos-latest' && matrix.llvm != ''
185+
run: |
186+
SDK_PATH=$(xcrun --sdk macosx --show-sdk-path)
187+
if [ ! -f "$SDK_PATH/usr/lib/libcurses.tbd" ] && [ -f "$SDK_PATH/usr/lib/libncurses.tbd" ]; then
188+
# 1. Patch hardcoded libcurses.tbd paths in LLVM cmake config files
189+
LLVM_PREFIX="${LLVM_PATH:-$GITHUB_WORKSPACE/llvm}"
190+
LLVM_CMAKE_DIR="$LLVM_PREFIX/lib/cmake/llvm"
191+
if [ -d "$LLVM_CMAKE_DIR" ]; then
192+
grep -rl 'libcurses\.tbd' "$LLVM_CMAKE_DIR" 2>/dev/null | while IFS= read -r f; do
193+
sed -i '' 's|/usr/lib/libcurses\.tbd|/usr/lib/libncurses.tbd|g' "$f"
194+
done
195+
fi
196+
# 2. Provide a symlink via CMAKE_PREFIX_PATH so find_library(curses)
197+
# can resolve to libncurses when the path is resolved dynamically
198+
TMP_CURSES_DIR=$(mktemp -d)
199+
mkdir -p "$TMP_CURSES_DIR/lib"
200+
ln -s "$SDK_PATH/usr/lib/libncurses.tbd" "$TMP_CURSES_DIR/lib/libcurses.tbd"
201+
echo "CMAKE_PREFIX_PATH=$TMP_CURSES_DIR" >> "$GITHUB_ENV"
202+
fi
183203
- name: Run tests (LLVM)
184204
if : matrix.tests && matrix.llvm != ''
185205
run: cargo test --verbose --features llvm${{ matrix.llvm[1] }} --features "${{ matrix.features }}"

book/src/functions.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The DiffSL supports the following mathematical functions that can be used in an
2020
* `abs(x)` - absolute value of x
2121
* `sigmoid(x)` - sigmoid function of x
2222
* `heaviside(x)` - Heaviside step function of x
23+
* `interp1d(x_i, y_i, q)` - 1D piecewise linear interpolation
2324

2425
You can use these functions as part of an expression in the DSL. For example, to define a variable `a` that is the sine of another variable `b`, you can write:
2526

@@ -28,6 +29,33 @@ b { 1.0 }
2829
a { sin(b) }
2930
```
3031

32+
### `interp1d(x_i, y_i, q)`
33+
34+
The `interp1d` function performs 1D piecewise linear interpolation. `x_i` and `y_i`
35+
must be compile-time constant 1D dense tensors of equal length. The query point
36+
`q` can be a scalar or a tensor. Out-of-range queries are clamped to the
37+
endpoints.
38+
39+
`x_i` must be **strictly increasing**; an error is reported at compile time if
40+
it is not.
41+
42+
If `x_i` is uniformly spaced (constant increment `dx` between consecutive values),
43+
the interpolation runs in **O(1)** per query point. For non-uniform `x_i`, a
44+
binary search is used, giving **O(log n)** cost per query point.
45+
46+
```diffsl
47+
xs_i { 0.0, 10.0, 20.0 }
48+
ys_i { 1.0, 5.0, 15.0 }
49+
r { interp1d(xs_i, ys_i, 5.0) } # => 3.0
50+
51+
# batched queries
52+
q_j { 0.0, 5.0, 10.0, 15.0, 20.0 }
53+
r_j { interp1d(xs_i, ys_i, q_j) } # => [1.0, 3.0, 5.0, 10.0, 15.0]
54+
55+
# the first two arguments may be any constant 1D dense expression
56+
r { interp1d(xs_i * 2.0, ys_i + 1.0, 5.0) }
57+
```
58+
3159
## Pre-defined variables
3260

3361
There are two predefined variables in DiffSL. The first is the scalar `t` which is the current time, this allows the equations to be written as functions of time. For example

diffsl/src/ast/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ impl<'a> AstKind<'a> {
525525
}
526526
}
527527

528-
#[derive(Debug, Copy, Clone)]
528+
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
529529
pub struct StringSpan {
530530
pub pos_start: usize,
531531
pub pos_end: usize,
@@ -914,8 +914,14 @@ impl<'a> Ast<'a> {
914914
monop.child.collect_indices(indices);
915915
}
916916
AstKind::Call(call) => {
917-
for c in &call.args {
918-
c.collect_indices(indices);
917+
if call.fn_name == "interp1d" {
918+
if let Some(q) = call.args.get(2) {
919+
q.collect_indices(indices);
920+
}
921+
} else {
922+
for c in &call.args {
923+
c.collect_indices(indices);
924+
}
919925
}
920926
}
921927
AstKind::CallArg(arg) => {

diffsl/src/continuous/builder.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -534,13 +534,18 @@ impl<'s> ModelInfo<'s> {
534534
}
535535
AstKind::Call(call) => {
536536
// check name in allowed functions
537-
let functions = ["sin", "cos", "tan", "pow", "exp", "log", "sqrt", "abs"];
537+
let functions = [
538+
"sin", "cos", "tan", "pow", "exp", "log", "sqrt", "abs", "interp1d",
539+
];
538540
if functions.contains(&call.fn_name) {
539-
// built in functions all have 1 arg
540-
// built in functions should have no keyword args
541-
if call.args.len() != 1 {
541+
let expected_nargs = match call.fn_name {
542+
"interp1d" => 3,
543+
"pow" => 2,
544+
_ => 1,
545+
};
546+
if call.args.len() != expected_nargs {
542547
self.errors.push(Output::new(
543-
format!("incorrect number of given arguments ({} instead of {}) for function {}", call.args.len(), 1, call.fn_name),
548+
format!("incorrect number of given arguments ({} instead of {}) for function {}", call.args.len(), expected_nargs, call.fn_name),
544549
expr.span,
545550
));
546551
}

diffsl/src/discretise/discrete_model.rs

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,37 @@ impl<'s> DiscreteModel<'s> {
10541054
self.reset.as_ref()
10551055
}
10561056

1057+
pub fn all_tensors(&self) -> Vec<&Tensor<'_>> {
1058+
let mut tensors = Vec::new();
1059+
tensors.extend(self.constant_defns.iter());
1060+
tensors.extend(self.input_dep_defns.iter());
1061+
tensors.extend(self.time_dep_defns.iter());
1062+
tensors.extend(self.state_dep_defns.iter());
1063+
tensors.extend(self.state_dep_post_f_defns.iter());
1064+
tensors.extend(self.dstate_dep_defns.iter());
1065+
tensors.push(&self.state);
1066+
if let Some(sd) = &self.state_dot {
1067+
tensors.push(sd);
1068+
}
1069+
if let Some(inp) = &self.input {
1070+
tensors.push(inp);
1071+
}
1072+
if let Some(lhs) = &self.lhs {
1073+
tensors.push(lhs);
1074+
}
1075+
tensors.push(&self.rhs);
1076+
if let Some(out) = &self.out {
1077+
tensors.push(out);
1078+
}
1079+
if let Some(reset) = &self.reset {
1080+
tensors.push(reset);
1081+
}
1082+
if let Some(stop) = &self.stop {
1083+
tensors.push(stop);
1084+
}
1085+
tensors
1086+
}
1087+
10571088
pub fn take_state0_input_deps(&mut self) -> Vec<(usize, usize)> {
10581089
std::mem::take(&mut self.state0_input_deps)
10591090
}
@@ -2026,7 +2057,7 @@ mod tests {
20262057
);
20272058
assert!(c.elmts()[0].has_values());
20282059

2029-
let layout = DataLayout::new(&model);
2060+
let layout = DataLayout::new(&model).unwrap();
20302061
assert_eq!(layout.get_tensor_constants("C").unwrap(), &[2.0, 5.0]);
20312062
}
20322063

@@ -2149,4 +2180,84 @@ mod tests {
21492180
);
21502181
}
21512182
}
2183+
2184+
#[test]
2185+
fn test_interp1d_basic() {
2186+
let text = "
2187+
xs_i { 0.0, 10.0, 20.0 }
2188+
ys_i { 1.0, 5.0, 15.0 }
2189+
u_i { z = 1 }
2190+
F_i { interp1d(xs_i, ys_i, 5.0) }
2191+
";
2192+
let model = parse_ds_string(text).unwrap();
2193+
let discrete = DiscreteModel::build("$name", &model).unwrap();
2194+
assert_eq!(discrete.constant_defns().len(), 2); // xs and ys are constants
2195+
assert_eq!(discrete.rhs.elmts().len(), 1);
2196+
}
2197+
2198+
#[test]
2199+
fn test_interp1d_batched() {
2200+
let text = "
2201+
xs_i { 0.0, 10.0, 20.0 }
2202+
ys_i { 1.0, 5.0, 15.0 }
2203+
q_j { 0.0, 15.0 }
2204+
u_j { z = 1, w = 1 }
2205+
F_j { interp1d(xs_i, ys_i, q_j) }
2206+
";
2207+
let model = parse_ds_string(text).unwrap();
2208+
let discrete = DiscreteModel::build("$name", &model).unwrap();
2209+
assert_eq!(discrete.rhs.elmts().len(), 1);
2210+
assert_eq!(discrete.state.elmts().len(), 2);
2211+
}
2212+
2213+
#[test]
2214+
fn test_interp1d_sortedness_error() {
2215+
let text = "
2216+
xs_i { 10.0, 0.0, 20.0 }
2217+
ys_i { 1.0, 5.0, 15.0 }
2218+
u_i { z = 1 }
2219+
F_i { interp1d(xs_i, ys_i, 5.0) }
2220+
";
2221+
let model = parse_ds_string(text).unwrap();
2222+
let discrete = DiscreteModel::build("$name", &model).unwrap();
2223+
let err = DataLayout::new(&discrete).unwrap_err();
2224+
assert!(
2225+
err.has_error_contains("strictly increasing"),
2226+
"expected sortedness error, got: {}",
2227+
err
2228+
);
2229+
}
2230+
2231+
#[test]
2232+
fn test_interp1d_nonconstant_x() {
2233+
let text = "
2234+
in { a = 1 }
2235+
u_i { z = 1 }
2236+
F_i { interp1d(u_i, u_i, 5.0) }
2237+
";
2238+
let model = parse_ds_string(text).unwrap();
2239+
let err = DiscreteModel::build("$name", &model).unwrap_err();
2240+
assert!(
2241+
err.has_error_contains("compile-time constant"),
2242+
"expected constant error in: {}",
2243+
err.as_error_message(text)
2244+
);
2245+
}
2246+
2247+
#[test]
2248+
fn test_interp1d_shape_mismatch() {
2249+
let text = "
2250+
xs_i { 0.0, 10.0, 20.0 }
2251+
ys_j { 1.0, 5.0 }
2252+
u_i { z = 1 }
2253+
F_i { interp1d(xs_i, ys_j, 5.0) }
2254+
";
2255+
let model = parse_ds_string(text).unwrap();
2256+
let err = DiscreteModel::build("$name", &model).unwrap_err();
2257+
assert!(
2258+
err.has_error_contains("cannot find index j"),
2259+
"expected index mismatch error in: {}",
2260+
err.as_error_message(text)
2261+
);
2262+
}
21522263
}

diffsl/src/discretise/env.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,9 @@ impl Env {
524524
ast: &Ast,
525525
indices: &Vec<char>,
526526
) -> Option<Layout> {
527+
if call.fn_name == "interp1d" {
528+
return self.get_layout_interp1d(call, ast, indices);
529+
}
527530
let layouts = call
528531
.args
529532
.iter()
@@ -539,6 +542,90 @@ impl Env {
539542
}
540543
}
541544

545+
fn get_layout_interp1d(
546+
&mut self,
547+
call: &ast::Call,
548+
ast: &Ast,
549+
indices: &Vec<char>,
550+
) -> Option<Layout> {
551+
if call.args.len() != 3 {
552+
self.errs.push(ValidationError::new(
553+
format!(
554+
"interp1d requires 3 arguments (x_i, y_i, q), got {}",
555+
call.args.len()
556+
),
557+
ast.span,
558+
));
559+
return None;
560+
}
561+
562+
for (i, arg) in [&call.args[0], &call.args[1]].iter().enumerate() {
563+
let deps = arg.get_dependents();
564+
for name in &deps {
565+
if self.get(name).is_none_or(|v| !v.is_constant()) {
566+
self.errs.push(ValidationError::new(
567+
format!(
568+
"interp1d argument {} depends on '{}' which is not compile-time constant",
569+
i + 1,
570+
name
571+
),
572+
ast.span,
573+
));
574+
return None;
575+
}
576+
}
577+
}
578+
579+
let shared_index = call.args[0].get_indices().into_iter().next().unwrap_or({
580+
// No index found — the expression is scalar, which will fail the rank check below.
581+
// Use a dummy index so get_layout can give a meaningful error.
582+
' '
583+
});
584+
let ctx = vec![shared_index];
585+
let x_layout = self.get_layout(&call.args[0], &ctx)?;
586+
let y_layout = self.get_layout(&call.args[1], &ctx)?;
587+
588+
if x_layout.rank() != 1 {
589+
self.errs.push(ValidationError::new(
590+
format!(
591+
"interp1d first argument must be 1D, got rank {}",
592+
x_layout.rank()
593+
),
594+
ast.span,
595+
));
596+
return None;
597+
}
598+
if !x_layout.is_dense() {
599+
self.errs.push(ValidationError::new(
600+
"interp1d first argument must be a dense vector".to_string(),
601+
ast.span,
602+
));
603+
return None;
604+
}
605+
if !y_layout.is_dense() {
606+
self.errs.push(ValidationError::new(
607+
"interp1d second argument must be a dense vector".to_string(),
608+
ast.span,
609+
));
610+
return None;
611+
}
612+
if y_layout.shape() != x_layout.shape() {
613+
self.errs.push(ValidationError::new(
614+
format!(
615+
"interp1d: x and y must have the same shape, got {} and {}",
616+
x_layout.shape(),
617+
y_layout.shape()
618+
),
619+
ast.span,
620+
));
621+
return None;
622+
}
623+
624+
let mut q_layout = self.get_layout(&call.args[2], indices)?;
625+
q_layout.to_dense();
626+
Some(q_layout)
627+
}
628+
542629
pub fn get_layout(&mut self, ast: &Ast, indices: &Vec<char>) -> Option<Layout> {
543630
let layout = match &ast.kind {
544631
AstKind::Assignment(a) => self.get_layout(a.expr.as_ref(), indices),

diffsl/src/discretise/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,5 @@ impl fmt::Display for ValidationErrors {
8282
Ok(())
8383
}
8484
}
85+
86+
impl std::error::Error for ValidationErrors {}

0 commit comments

Comments
 (0)