diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..210b3f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '11' + + - name: Build + run: ant compile + + - name: Test + run: ant test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c93ddcb --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,48 @@ +name: Deploy Docs + +on: + push: + branches: [ main ] + paths: + - 'docsite/**' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install MkDocs + run: pip install mkdocs + + - name: Build site + run: mkdocs build + working-directory: docsite + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docsite/site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/build.xml b/build.xml index eb7fd18..c37aaa8 100644 --- a/build.xml +++ b/build.xml @@ -50,20 +50,57 @@ + + - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docsite/docs/module-developer-guide.md b/docsite/docs/module-developer-guide.md new file mode 100644 index 0000000..136a6df --- /dev/null +++ b/docsite/docs/module-developer-guide.md @@ -0,0 +1,301 @@ +# Module Developer Guide + +This document describes the internals of the QIT module framework for developers +who need to extend, maintain, or understand the codebase. For a user-facing +overview, see [modules.md](modules.md). + +## Architecture Overview + +The module framework has four layers: + +1. **Annotations** (`qit.base.annot.*`) — declarative metadata on classes and fields +2. **Module interface** (`qit.base.Module`) — the contract every module implements +3. **Reflection engine** (`qit.base.utils.ModuleUtils`) — discovers modules, extracts field metadata, validates, serializes +4. **CLI binding** (`qit.base.cli.*`) — wraps modules for command-line execution with auto-generated help + +``` + ┌──────────────┐ + │ QitMain │ entry point, module discovery + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ CliModule │ CLI wrapper, arg parsing, I/O + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ ModuleUtils │ reflection, validation, field access + └──────┬───────┘ + │ + ┌─────────▼─────────┐ + │ Module + Annots │ user-written module code + └───────────────────┘ +``` + +## Design Philosophy + +QIT modules are **pure data transforms**: they read from input fields, compute a +result, and write to output fields. A module should not manage file discovery, +iterate over subjects, handle dependencies between processing steps, or +implement retry logic. These concerns belong to external orchestration tools. + +This is a deliberate design choice. By keeping modules stateless and +single-purpose, they remain composable across many different execution contexts: + +- **Command line** — chain modules with shell scripts or Makefiles +- **LONI Pipeline** — QIT modules integrate directly with the + [LONI Pipeline](http://pipeline.loni.usc.edu) workflow engine, which handles + execution graphs, parallelism, and provenance tracking +- **Workflow managers** — tools like Nextflow, Snakemake, or Nipype can invoke + `qit ...` as a process step, managing dependencies and scheduling +- **qitview** — the interactive viewer wraps modules in a GUI, letting users + apply transforms to loaded data without writing scripts + +Workflow orchestration tools evolve independently and have their own strengths +(DAG scheduling, cluster support, checkpointing, provenance). By not +reimplementing these capabilities inside QIT, modules stay simple, testable, and +durable — they will work with whatever orchestration tools exist today or in the +future. + +**Rule of thumb:** if you find yourself adding loops over subjects, file +globbing, or conditional branching inside a module, that logic should live in an +external script or workflow definition instead. + +## Annotations Reference + +All annotations are in `qit/base/annot/`. All use `@Retention(RUNTIME)` and +`@Inherited`. + +### Class-level annotations + +| Annotation | Target | Purpose | +|---|---|---| +| `@ModuleDescription("...")` | Class or Field | Plain-text description shown in help | +| `@ModuleAuthor("...")` | Class | Author attribution | +| `@ModuleCitation("...")` | Class | Publication to cite when using the module | +| `@ModuleUnlisted` | Class | Hides the module from `--list` and help output | + +### Field-level annotations + +Every annotated field must have exactly one role annotation: + +| Annotation | Role | Typical types | +|---|---|---| +| `@ModuleInput` | Data consumed by the module | Dataset subclasses: `Volume`, `Mask`, `Curves`, `Mesh`, `Vects`, `Table`, `Matrix`, `Affine`, `Gradients`, `Deformation`, `Neuron`, `Solids` | +| `@ModuleOutput` | Data produced by the module | Same Dataset subclasses | +| `@ModuleParameter` | Configuration value | `Double`, `Integer`, `String`, `Boolean`, `boolean`, enums | + +Field modifiers (combinable with a role annotation): + +| Annotation | Effect | +|---|---| +| `@ModuleOptional` | Field may be null; not required on the command line | +| `@ModuleAdvanced` | Grouped separately in help output; not typically modified | +| `@ModuleExpert` | Only visible when `--expert` flag is passed | + +### Validation rules + +`ModuleUtils.validate()` enforces: + +- Each annotated field has exactly one role (`@ModuleInput` XOR `@ModuleParameter` XOR `@ModuleOutput`) +- `@ModuleOutput` fields are not marked `@ModuleAdvanced` +- Input/output field types are valid Dataset subclasses + +## Module Interface + +```java +public interface Module { + Module run() throws IOException; +} +``` + +Modules must: + +- Implement a public no-argument constructor +- Declare annotated `public` fields for inputs, parameters, and outputs +- Implement `run()` which reads from input fields, writes to output fields, and returns `this` + +The framework populates input and parameter fields before calling `run()`, and +reads output fields after it returns. + +## How Module Discovery Works + +`QitMain.main()` → `ModuleUtils.list()`: + +1. Uses `org.reflections.Reflections` to scan the `"qit"` package at runtime +2. Finds all classes implementing `Module` +3. Filters out inner classes and `@ModuleUnlisted` classes +4. Maps simple class names (e.g. `VolumeThreshold`) to classes +5. Also discovers `CliMain` implementations for batch processing tools + +A user invokes a module by name: +``` +qit VolumeThreshold --input in.nii.gz --output out.nii.gz +``` + +`QitMain` looks up `VolumeThreshold`, wraps it in `CliModule`, and calls `run()`. + +## CLI Binding Flow + +`CliModule` is the adapter between `Module` and the command line: + +### 1. Build CLI specification (`cli()` method) + +For each field returned by `ModuleUtils.fields(module)`: + +- Check `@ModuleExpert` visibility (skip if not in expert mode) +- Determine role from `@ModuleInput` / `@ModuleParameter` / `@ModuleOutput` +- Read `@ModuleDescription` for help text +- Read `@ModuleOptional` / `@ModuleAdvanced` for categorization +- For `boolean` fields: create a flag (no argument value needed) +- For `enum` fields: auto-generate option list from enum constants +- For Dataset fields: use the type name as the argument placeholder +- Build a `CliOption` and add it to `CliSpecification` + +### 2. Parse arguments + +`CliValues` parses `--key value` pairs and positional arguments from `String[] args`. + +### 3. Read inputs (`read()` method) + +For each input/parameter field: + +- Get the string value from `CliValues` +- For Dataset types: call the type's static `read(filename)` method via reflection +- For primitives: parse from string (`Double.valueOf()`, etc.) +- For enums: `Enum.valueOf()` +- For booleans: presence of the flag means `true` + +### 4. Execute + +Call `module.run()`. + +### 5. Write outputs (`write()` method) + +For each `@ModuleOutput` field: + +- Get the filename from `CliValues` +- Call the Dataset's `write(filename)` method via reflection + +## Writing a New Module + +### Minimal example + +```java +@ModuleDescription("Scale a volume by a constant factor") +@ModuleAuthor("Your Name") +public class VolumeScale implements Module +{ + @ModuleInput + @ModuleDescription("input volume") + public Volume input; + + @ModuleParameter + @ModuleDescription("scale factor") + public Double factor = 1.0; + + @ModuleOutput + @ModuleDescription("output volume") + public Volume output; + + @Override + public VolumeScale run() + { + this.output = this.input.copy(); + // ... scale voxel values by this.factor ... + return this; + } +} +``` + +This automatically provides: + +- `qit VolumeScale --help` with formatted help text +- `qit VolumeScale --input in.nii.gz --factor 2.0 --output out.nii.gz` +- Availability in `qitview` module panel +- JSON serialization of parameters via `--save`/`--load` + +### Conventions + +- Place modules in `qit/data/modules//` (e.g. `volume/`, `mask/`, `curves/`) +- Class name should be `` (e.g. `VolumeThreshold`, `MaskInvert`, `CurvesTransform`) +- Always set default values for `@ModuleParameter` fields +- Mark non-essential inputs with `@ModuleOptional` +- Return `this` from `run()` + +### Using @ModuleOptional inputs + +```java +@ModuleInput +@ModuleOptional +@ModuleDescription("optional mask to restrict processing") +public Mask mask; + +public MyModule run() +{ + for (Sample sample : this.input.getSampling()) + { + if (this.input.valid(sample, this.mask)) // mask can be null + { + // process voxel + } + } + return this; +} +``` + +### Using enum parameters + +```java +public enum InterpolationType { NEAREST, LINEAR, CUBIC } + +@ModuleParameter +@ModuleDescription("interpolation method") +public InterpolationType interp = InterpolationType.LINEAR; +``` + +The CLI will automatically show: `--interp (Options: NEAREST, LINEAR, CUBIC) (Default: LINEAR)` + +## Batch Processing Tools + +For operations across multiple subjects/files, create a `CliMain` implementation +in `qit/main/`: + +```java +@ModuleDescription("Run VolumeThreshold on many subjects") +@ModuleAuthor("Your Name") +public class VolumeThresholdBatch implements CliMain +{ + public void execute(CliValues args) throws Exception + { + // Custom batch logic using args.keyed("--input"), etc. + } +} +``` + +These are discovered alongside Modules and invoked the same way: +`qit VolumeThresholdBatch --help` + +## Module Serialization + +Modules can be saved and loaded as JSON: + +``` +qit VolumeThreshold --input in.nii.gz --threshold 0.3 --output out.nii.gz --save params.json +qit VolumeThreshold --load params.json --input other.nii.gz --output other_out.nii.gz +``` + +`ModuleUtils.write()` serializes annotated field values to JSON. +`ModuleUtils.read()` deserializes and populates fields. + +## Key Source Files + +| File | Role | +|---|---| +| `qit/base/Module.java` | Module interface (10 lines) | +| `qit/base/annot/*.java` | All annotation definitions | +| `qit/base/utils/ModuleUtils.java` | Reflection engine: discovery, validation, field access, serialization | +| `qit/base/cli/CliModule.java` | CLI adapter: arg parsing, file I/O, help generation | +| `qit/base/cli/CliSpecification.java` | Help text and Markdown generation | +| `qit/base/cli/CliOption.java` | Single CLI option metadata | +| `qit/base/cli/CliValues.java` | Parsed command-line arguments | +| `qit/main/QitMain.java` | Entry point, module discovery and dispatch | diff --git a/docsite/docs/modules.md b/docsite/docs/modules.md index 48261df..9340fd1 100644 --- a/docsite/docs/modules.md +++ b/docsite/docs/modules.md @@ -51,6 +51,19 @@ script, which are not typically integrated into data viewers or support many file formats. Finally, each `Module` also supports meta-data that includes the author, associated publications, and plain english descriptions. +## How do modules fit into a workflow? + +Each module is a self-contained data transform — it reads inputs, processes +them, and writes outputs. Modules do not manage multi-step workflows themselves. +Instead, you compose modules into pipelines using external orchestration tools: + +- **Shell scripts** or **Makefiles** for simple sequential pipelines +- **[LONI Pipeline](http://pipeline.loni.usc.edu)** for visual workflow design with built-in parallelism and provenance — QIT modules integrate directly with LONI Pipeline +- **Nextflow**, **Snakemake**, or **Nipype** for reproducible neuroimaging workflows with cluster support + +This keeps each module simple and reusable across different execution +environments. + ## What makes up a `Module`? Each `Module` has some number of fields with annotations to indicate diff --git a/docsite/mkdocs.yml b/docsite/mkdocs.yml index 1fdf10c..81d38cf 100644 --- a/docsite/mkdocs.yml +++ b/docsite/mkdocs.yml @@ -30,6 +30,7 @@ nav: - Colormaps: colormaps.md - Datasets: datasets.md - Modules: modules.md + - Module Developer Guide: module-developer-guide.md - Models: models.md - Tutorials: - Workflow Tutorial: workflow.md diff --git a/lib/test-jars/junit-platform-console-standalone-1.10.2.jar b/lib/test-jars/junit-platform-console-standalone-1.10.2.jar new file mode 100644 index 0000000..7ca10e6 Binary files /dev/null and b/lib/test-jars/junit-platform-console-standalone-1.10.2.jar differ diff --git a/test/qit/base/utils/ModuleValidationTest.java b/test/qit/base/utils/ModuleValidationTest.java new file mode 100644 index 0000000..763a06c --- /dev/null +++ b/test/qit/base/utils/ModuleValidationTest.java @@ -0,0 +1,39 @@ +package qit.base.utils; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import qit.base.Module; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class ModuleValidationTest +{ + @Test + void moduleDiscoveryFindsModules() + { + Map> modules = ModuleUtils.listedClasses(); + assertTrue(modules.size() > 100, + "Expected 100+ listed modules, found " + modules.size()); + } + + @TestFactory + Stream allModulesPassValidation() + { + List modules = ModuleUtils.list(); + + return modules.stream().map(module -> + DynamicTest.dynamicTest(module.getClass().getSimpleName(), () -> + { + List errors = ModuleUtils.report(module); + assertTrue(errors.isEmpty(), + module.getClass().getSimpleName() + " has validation errors:\n" + + String.join("\n", errors)); + }) + ); + } +} diff --git a/test/qit/data/datasets/MatrixTest.java b/test/qit/data/datasets/MatrixTest.java new file mode 100644 index 0000000..1591bdf --- /dev/null +++ b/test/qit/data/datasets/MatrixTest.java @@ -0,0 +1,592 @@ +package qit.data.datasets; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class MatrixTest +{ + private static final double EPS = 1e-9; + + // ===== construction ===== + + @Test + void constructRowsCols() + { + Matrix m = new Matrix(3, 4); + assertEquals(3, m.rows()); + assertEquals(4, m.cols()); + assertEquals(0.0, m.get(0, 0), EPS); + } + + @Test + void constructWithValue() + { + Matrix m = new Matrix(2, 3, 5.0); + assertEquals(2, m.rows()); + assertEquals(3, m.cols()); + for (int i = 0; i < 2; i++) + { + for (int j = 0; j < 3; j++) + { + assertEquals(5.0, m.get(i, j), EPS); + } + } + } + + @Test + void constructFromArray() + { + double[][] data = {{1, 2}, {3, 4}}; + Matrix m = new Matrix(data); + assertEquals(2, m.rows()); + assertEquals(2, m.cols()); + assertEquals(1.0, m.get(0, 0), EPS); + assertEquals(4.0, m.get(1, 1), EPS); + } + + // ===== accessors ===== + + @Test + void setAndGet() + { + Matrix m = new Matrix(2, 2); + m.set(0, 0, 1.0); + m.set(0, 1, 2.0); + m.set(1, 0, 3.0); + m.set(1, 1, 4.0); + assertEquals(1.0, m.get(0, 0), EPS); + assertEquals(2.0, m.get(0, 1), EPS); + assertEquals(3.0, m.get(1, 0), EPS); + assertEquals(4.0, m.get(1, 1), EPS); + } + + @Test + void isSquare() + { + assertTrue(new Matrix(3, 3).isSquare()); + assertFalse(new Matrix(2, 3).isSquare()); + } + + @Test + void compatible() + { + Matrix a = new Matrix(2, 3); + Matrix b = new Matrix(2, 3); + Matrix c = new Matrix(3, 2); + assertTrue(a.compatible(b)); + assertFalse(a.compatible(c)); + } + + // ===== row/column access ===== + + @Test + void getRowAndColumn() + { + Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}}); + Vect row = m.getRow(0); + assertEquals(3, row.size()); + assertEquals(1.0, row.get(0), EPS); + assertEquals(2.0, row.get(1), EPS); + assertEquals(3.0, row.get(2), EPS); + + Vect col = m.getColumn(1); + assertEquals(2, col.size()); + assertEquals(2.0, col.get(0), EPS); + assertEquals(5.0, col.get(1), EPS); + } + + @Test + void setRowAndColumn() + { + Matrix m = new Matrix(2, 3); + m.setRow(0, new Vect(new double[]{1, 2, 3})); + assertEquals(1.0, m.get(0, 0), EPS); + assertEquals(2.0, m.get(0, 1), EPS); + assertEquals(3.0, m.get(0, 2), EPS); + + m.setColumn(1, new Vect(new double[]{10, 20})); + assertEquals(10.0, m.get(0, 1), EPS); + assertEquals(20.0, m.get(1, 1), EPS); + } + + // ===== setAll ===== + + @Test + void setAll() + { + Matrix m = new Matrix(2, 2); + m.setAll(7.0); + assertEquals(7.0, m.get(0, 0), EPS); + assertEquals(7.0, m.get(1, 1), EPS); + } + + @Test + void setAllDiag() + { + Matrix m = new Matrix(3, 3); + m.setAllDiag(1.0); + assertEquals(1.0, m.get(0, 0), EPS); + assertEquals(1.0, m.get(1, 1), EPS); + assertEquals(1.0, m.get(2, 2), EPS); + assertEquals(0.0, m.get(0, 1), EPS); + } + + // ===== scalar arithmetic ===== + + @Test + void timesScalar() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix result = m.times(2.0); + assertEquals(2.0, result.get(0, 0), EPS); + assertEquals(8.0, result.get(1, 1), EPS); + // original unchanged + assertEquals(1.0, m.get(0, 0), EPS); + } + + @Test + void timesEqualsScalar() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + m.timesEquals(3.0); + assertEquals(3.0, m.get(0, 0), EPS); + assertEquals(12.0, m.get(1, 1), EPS); + } + + // ===== matrix arithmetic ===== + + @Test + void plusMatrix() + { + Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}}); + Matrix c = a.plus(b); + assertEquals(6.0, c.get(0, 0), EPS); + assertEquals(8.0, c.get(0, 1), EPS); + assertEquals(10.0, c.get(1, 0), EPS); + assertEquals(12.0, c.get(1, 1), EPS); + } + + @Test + void minusMatrix() + { + Matrix a = new Matrix(new double[][]{{5, 6}, {7, 8}}); + Matrix b = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix c = a.minus(b); + assertEquals(4.0, c.get(0, 0), EPS); + assertEquals(4.0, c.get(0, 1), EPS); + assertEquals(4.0, c.get(1, 0), EPS); + assertEquals(4.0, c.get(1, 1), EPS); + } + + @Test + void timesMatrix() + { + // [[1,2],[3,4]] * [[5,6],[7,8]] = [[19,22],[43,50]] + Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}}); + Matrix c = a.times(b); + assertEquals(19.0, c.get(0, 0), EPS); + assertEquals(22.0, c.get(0, 1), EPS); + assertEquals(43.0, c.get(1, 0), EPS); + assertEquals(50.0, c.get(1, 1), EPS); + } + + @Test + void timesVect() + { + // [[1,2],[3,4]] * [5,6] = [17,39] + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Vect v = new Vect(new double[]{5, 6}); + Vect result = m.times(v); + assertEquals(2, result.size()); + assertEquals(17.0, result.get(0), EPS); + assertEquals(39.0, result.get(1), EPS); + } + + @Test + void timesElem() + { + Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}}); + Matrix c = a.timesElem(b); + assertEquals(5.0, c.get(0, 0), EPS); + assertEquals(12.0, c.get(0, 1), EPS); + assertEquals(21.0, c.get(1, 0), EPS); + assertEquals(32.0, c.get(1, 1), EPS); + } + + @Test + void plusScaledMatrix() + { + // a + s*b + Matrix a = new Matrix(new double[][]{{1, 0}, {0, 1}}); + Matrix b = new Matrix(new double[][]{{2, 2}, {2, 2}}); + Matrix c = a.plus(0.5, b); + assertEquals(2.0, c.get(0, 0), EPS); + assertEquals(1.0, c.get(0, 1), EPS); + assertEquals(1.0, c.get(1, 0), EPS); + assertEquals(2.0, c.get(1, 1), EPS); + } + + // ===== row/col broadcast ===== + + @Test + void plusRows() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Vect v = new Vect(new double[]{10, 20}); + Matrix result = m.plusRows(v); + assertEquals(11.0, result.get(0, 0), EPS); + assertEquals(22.0, result.get(0, 1), EPS); + assertEquals(13.0, result.get(1, 0), EPS); + assertEquals(24.0, result.get(1, 1), EPS); + } + + @Test + void minusRows() + { + Matrix m = new Matrix(new double[][]{{11, 22}, {13, 24}}); + Vect v = new Vect(new double[]{10, 20}); + Matrix result = m.minusRows(v); + assertEquals(1.0, result.get(0, 0), EPS); + assertEquals(2.0, result.get(0, 1), EPS); + } + + @Test + void plusCols() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Vect v = new Vect(new double[]{10, 20}); + Matrix result = m.plusCols(v); + assertEquals(11.0, result.get(0, 0), EPS); + assertEquals(12.0, result.get(0, 1), EPS); + assertEquals(23.0, result.get(1, 0), EPS); + assertEquals(24.0, result.get(1, 1), EPS); + } + + // ===== linear algebra (JAMA) ===== + + @Test + void transpose() + { + Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}}); + Matrix t = m.transpose(); + assertEquals(3, t.rows()); + assertEquals(2, t.cols()); + assertEquals(1.0, t.get(0, 0), EPS); + assertEquals(4.0, t.get(0, 1), EPS); + assertEquals(2.0, t.get(1, 0), EPS); + assertEquals(5.0, t.get(1, 1), EPS); + } + + @Test + void transposeDoubleRoundtrip() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix tt = m.transpose().transpose(); + for (int i = 0; i < 2; i++) + { + for (int j = 0; j < 2; j++) + { + assertEquals(m.get(i, j), tt.get(i, j), EPS); + } + } + } + + @Test + void determinant2x2() + { + // det([[1,2],[3,4]]) = 1*4 - 2*3 = -2 + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + assertEquals(-2.0, m.det(), 1e-6); + } + + @Test + void determinant3x3() + { + // det([[1,0,0],[0,2,0],[0,0,3]]) = 6 + Matrix m = new Matrix(new double[][]{{1, 0, 0}, {0, 2, 0}, {0, 0, 3}}); + assertEquals(6.0, m.det(), 1e-6); + } + + @Test + void determinantIdentity() + { + Matrix id = new Matrix(3, 3); + id.setAllDiag(1.0); + assertEquals(1.0, id.det(), 1e-6); + } + + @Test + void inverse() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix inv = m.inv(); + + // m * inv should be identity + Matrix product = m.times(inv); + assertEquals(1.0, product.get(0, 0), 1e-6); + assertEquals(0.0, product.get(0, 1), 1e-6); + assertEquals(0.0, product.get(1, 0), 1e-6); + assertEquals(1.0, product.get(1, 1), 1e-6); + } + + @Test + void inverseRoundtrip() + { + Matrix m = new Matrix(new double[][]{{2, 1}, {5, 3}}); + Matrix roundtrip = m.inv().inv(); + for (int i = 0; i < 2; i++) + { + for (int j = 0; j < 2; j++) + { + assertEquals(m.get(i, j), roundtrip.get(i, j), 1e-6); + } + } + } + + @Test + void trace() + { + Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}); + assertEquals(15.0, m.trace(), EPS); + } + + @Test + void rank() + { + // identity has full rank + Matrix id = new Matrix(3, 3); + id.setAllDiag(1.0); + assertEquals(3, id.rank()); + + // zero matrix has rank 0 + assertEquals(0, new Matrix(3, 3).rank()); + } + + @Test + void diag() + { + Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}); + Vect d = m.diag(); + assertEquals(3, d.size()); + assertEquals(1.0, d.get(0), EPS); + assertEquals(5.0, d.get(1), EPS); + assertEquals(9.0, d.get(2), EPS); + } + + // ===== norms ===== + + @Test + void normF() + { + // Frobenius norm of [[1,2],[3,4]] = sqrt(1+4+9+16) = sqrt(30) + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + assertEquals(Math.sqrt(30.0), m.normF(), 1e-6); + } + + @Test + void sumsq() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + assertEquals(30.0, m.sumsq(), EPS); + } + + @Test + void inner() + { + Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}}); + // inner = 1*5 + 2*6 + 3*7 + 4*8 = 5+12+21+32 = 70 + assertEquals(70.0, a.inner(b), EPS); + } + + // ===== statistics ===== + + @Test + void mean() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + assertEquals(2.5, m.mean(), EPS); + } + + @Test + void meanRow() + { + // meanRow returns mean of each column (the "mean row") + Matrix m = new Matrix(new double[][]{{1, 4}, {3, 6}}); + Vect mr = m.meanRow(); + assertEquals(2, mr.size()); + assertEquals(2.0, mr.get(0), EPS); // mean of column 0: (1+3)/2 + assertEquals(5.0, mr.get(1), EPS); // mean of column 1: (4+6)/2 + } + + @Test + void meanColumn() + { + // meanColumn returns mean of each row + Matrix m = new Matrix(new double[][]{{1, 3}, {4, 6}}); + Vect mc = m.meanColumn(); + assertEquals(2, mc.size()); + assertEquals(2.0, mc.get(0), EPS); // mean of row 0: (1+3)/2 + assertEquals(5.0, mc.get(1), EPS); // mean of row 1: (4+6)/2 + } + + // ===== structural operations ===== + + @Test + void sub() + { + Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}); + Matrix s = m.sub(0, 1, 1, 2); + assertEquals(2, s.rows()); + assertEquals(2, s.cols()); + assertEquals(2.0, s.get(0, 0), EPS); + assertEquals(3.0, s.get(0, 1), EPS); + assertEquals(5.0, s.get(1, 0), EPS); + assertEquals(6.0, s.get(1, 1), EPS); + } + + @Test + void catRows() + { + Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix b = new Matrix(new double[][]{{5}, {6}}); + Matrix c = a.catRows(b); + assertEquals(2, c.rows()); + assertEquals(3, c.cols()); + assertEquals(5.0, c.get(0, 2), EPS); + assertEquals(6.0, c.get(1, 2), EPS); + } + + @Test + void catCols() + { + Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix b = new Matrix(new double[][]{{5, 6}}); + Matrix c = a.catCols(b); + assertEquals(3, c.rows()); + assertEquals(2, c.cols()); + assertEquals(5.0, c.get(2, 0), EPS); + assertEquals(6.0, c.get(2, 1), EPS); + } + + @Test + void flatten() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Vect f = m.flatten(); + assertEquals(4, f.size()); + assertEquals(1.0, f.get(0), EPS); + assertEquals(2.0, f.get(1), EPS); + assertEquals(3.0, f.get(2), EPS); + assertEquals(4.0, f.get(3), EPS); + } + + @Test + void packRow() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Vect p = m.packRow(); + assertEquals(4, p.size()); + // row-major: 1,2,3,4 + assertEquals(1.0, p.get(0), EPS); + assertEquals(2.0, p.get(1), EPS); + assertEquals(3.0, p.get(2), EPS); + assertEquals(4.0, p.get(3), EPS); + } + + @Test + void packColumn() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Vect p = m.packColumn(); + assertEquals(4, p.size()); + // column-major: 1,3,2,4 + assertEquals(1.0, p.get(0), EPS); + assertEquals(3.0, p.get(1), EPS); + assertEquals(2.0, p.get(2), EPS); + assertEquals(4.0, p.get(3), EPS); + } + + // ===== element-wise transforms ===== + + @Test + void expAndLog() + { + Matrix m = new Matrix(new double[][]{{0, 1}, {2, 3}}); + Matrix e = m.exp(); + assertEquals(1.0, e.get(0, 0), 1e-6); + assertEquals(Math.E, e.get(0, 1), 1e-6); + + // log(exp(m)) should round-trip + Matrix roundtrip = e.log(); + for (int i = 0; i < 2; i++) + { + for (int j = 0; j < 2; j++) + { + assertEquals(m.get(i, j), roundtrip.get(i, j), 1e-6); + } + } + } + + // ===== predicates ===== + + @Test + void nan() + { + Matrix good = new Matrix(new double[][]{{1, 2}, {3, 4}}); + assertFalse(good.nan()); + + Matrix bad = new Matrix(new double[][]{{1, Double.NaN}, {3, 4}}); + assertTrue(bad.nan()); + } + + @Test + void infinite() + { + Matrix good = new Matrix(new double[][]{{1, 2}, {3, 4}}); + assertFalse(good.infinite()); + + Matrix bad = new Matrix(new double[][]{{1, Double.POSITIVE_INFINITY}, {3, 4}}); + assertTrue(bad.infinite()); + } + + // ===== copy independence ===== + + @Test + void copy() + { + Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}}); + Matrix c = m.copy(); + assertEquals(m.get(0, 0), c.get(0, 0), EPS); + c.set(0, 0, 999.0); + assertEquals(1.0, m.get(0, 0), EPS); + } + + @Test + void proto() + { + Matrix m = new Matrix(3, 4); + Matrix p = m.proto(); + assertEquals(3, p.rows()); + assertEquals(4, p.cols()); + assertEquals(0.0, p.get(0, 0), EPS); + } + + // ===== homogeneous ===== + + @Test + void hom() + { + Matrix m = new Matrix(new double[][]{{1, 0}, {0, 1}}); + Matrix h = m.hom(); + assertEquals(3, h.rows()); + assertEquals(3, h.cols()); + assertEquals(1.0, h.get(0, 0), EPS); + assertEquals(1.0, h.get(1, 1), EPS); + assertEquals(1.0, h.get(2, 2), EPS); + assertEquals(0.0, h.get(0, 2), EPS); + } +} diff --git a/test/qit/data/datasets/SchemaTest.java b/test/qit/data/datasets/SchemaTest.java new file mode 100644 index 0000000..54bf3c2 --- /dev/null +++ b/test/qit/data/datasets/SchemaTest.java @@ -0,0 +1,175 @@ +package qit.data.datasets; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SchemaTest +{ + // ===== construction ===== + + @Test + void constructEmpty() + { + Schema s = new Schema(); + assertEquals(0, s.size()); + } + + @Test + void constructFromIterable() + { + Schema s = new Schema(Arrays.asList("x", "y", "z")); + assertEquals(3, s.size()); + assertEquals("x", s.getField(0)); + assertEquals("y", s.getField(1)); + assertEquals("z", s.getField(2)); + } + + // ===== add / size ===== + + @Test + void addField() + { + Schema s = new Schema(); + s.add("name"); + s.add("age"); + assertEquals(2, s.size()); + } + + @Test + void addFieldWithDefault() + { + Schema s = new Schema(); + s.add("color", "red"); + assertEquals("red", s.getDefault("color")); + assertEquals("red", s.getDefault(0)); + } + + @Test + void addDuplicateThrows() + { + Schema s = new Schema(); + s.add("name"); + assertThrows(RuntimeException.class, () -> s.add("name")); + } + + // ===== hasField ===== + + @Test + void hasField() + { + Schema s = new Schema(); + s.add("name"); + assertTrue(s.hasField("name")); + assertFalse(s.hasField("age")); + } + + // ===== getIndex ===== + + @Test + void getIndex() + { + Schema s = new Schema(); + s.add("a"); + s.add("b"); + s.add("c"); + assertEquals(0, s.getIndex("a")); + assertEquals(1, s.getIndex("b")); + assertEquals(2, s.getIndex("c")); + } + + @Test + void getIndexMissingThrows() + { + Schema s = new Schema(); + s.add("a"); + assertThrows(RuntimeException.class, () -> s.getIndex("z")); + } + + // ===== getField ===== + + @Test + void getField() + { + Schema s = new Schema(); + s.add("first"); + s.add("second"); + assertEquals("first", s.getField(0)); + assertEquals("second", s.getField(1)); + } + + // ===== getFields ===== + + @Test + void getFields() + { + Schema s = new Schema(Arrays.asList("a", "b", "c")); + List fields = s.getFields(); + assertEquals(3, fields.size()); + assertEquals("a", fields.get(0)); + assertEquals("c", fields.get(2)); + } + + // ===== lock ===== + + @Test + void lock() + { + Schema s = new Schema(); + assertFalse(s.locked()); + s.lock(); + assertTrue(s.locked()); + } + + // ===== copy ===== + + @Test + void copy() + { + Schema s = new Schema(); + s.add("x"); + s.add("y"); + Schema c = s.copy(); + assertEquals(s.size(), c.size()); + assertEquals("x", c.getField(0)); + assertEquals("y", c.getField(1)); + } + + @Test + void copyIndependence() + { + Schema s = new Schema(); + s.add("x"); + Schema c = s.copy(); + c.add("y"); + assertEquals(1, s.size()); + assertEquals(2, c.size()); + } + + // ===== iterator ===== + + @Test + void iterator() + { + Schema s = new Schema(Arrays.asList("a", "b", "c")); + int count = 0; + for (String field : s) + { + count++; + } + assertEquals(3, count); + } + + // ===== defaults ===== + + @Test + void defaultIsNullWhenNotSpecified() + { + Schema s = new Schema(); + s.add("name"); + assertNull(s.getDefault("name")); + } +} diff --git a/test/qit/data/datasets/TableTest.java b/test/qit/data/datasets/TableTest.java new file mode 100644 index 0000000..17dcfef --- /dev/null +++ b/test/qit/data/datasets/TableTest.java @@ -0,0 +1,356 @@ +package qit.data.datasets; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TableTest +{ + // ===== construction ===== + + @Test + void constructEmpty() + { + Table t = new Table(); + assertEquals(0, t.getNumRecords()); + assertEquals(0, t.getNumFields()); + } + + @Test + void constructFromSchema() + { + Schema s = new Schema(Arrays.asList("name", "age")); + Table t = new Table(s); + assertEquals(0, t.getNumRecords()); + assertEquals(2, t.getNumFields()); + assertTrue(t.hasField("name")); + assertTrue(t.hasField("age")); + } + + @Test + void constructFromIterable() + { + Table t = new Table(Arrays.asList("x", "y", "z")); + assertEquals(3, t.getNumFields()); + } + + // ===== addField ===== + + @Test + void addField() + { + Table t = new Table(); + t.addField("name"); + t.addField("value"); + assertEquals(2, t.getNumFields()); + assertTrue(t.hasField("name")); + assertTrue(t.hasField("value")); + } + + @Test + void addFieldIdempotent() + { + Table t = new Table(); + t.addField("name"); + t.addField("name"); // withField skips duplicates + assertEquals(1, t.getNumFields()); + } + + @Test + void addFields() + { + Table t = new Table(); + t.addFields(Arrays.asList("a", "b", "c")); + assertEquals(3, t.getNumFields()); + } + + // ===== addRecord ===== + + @Test + void addRecordFromArray() + { + Table t = new Table(); + t.addField("name"); + t.addField("value"); + t.addRecord(new String[]{"foo", "42"}); + assertEquals(1, t.getNumRecords()); + } + + @Test + void addRecordFromRecord() + { + Table t = new Table(); + t.addField("name"); + t.addField("value"); + Record r = new Record().with("name", "bar").with("value", "99"); + t.addRecord(r); + assertEquals(1, t.getNumRecords()); + } + + @Test + void addMultipleRecords() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + t.addRecord(new String[]{"2"}); + t.addRecord(new String[]{"3"}); + assertEquals(3, t.getNumRecords()); + } + + // ===== get / set ===== + + @Test + void getByKeyAndField() + { + Table t = new Table(); + t.addField("name"); + t.addField("value"); + Record r = new Record().with("name", "test").with("value", "123"); + t.addRecord(r); + + List keys = t.getKeys(); + assertEquals(1, keys.size()); + assertEquals("test", t.get(keys.get(0), "name")); + assertEquals("123", t.get(keys.get(0), "value")); + } + + @Test + void setByKeyAndField() + { + Table t = new Table(); + t.addField("name"); + t.set(1, "name", "hello"); + assertEquals("hello", t.get(1, "name")); + } + + @Test + void setCreatesFieldIfMissing() + { + Table t = new Table(); + t.set(1, "newfield", "val"); + assertTrue(t.hasField("newfield")); + assertEquals("val", t.get(1, "newfield")); + } + + // ===== field access ===== + + @Test + void getFields() + { + Table t = new Table(); + t.addField("a"); + t.addField("b"); + List fields = t.getFields(); + assertEquals(2, fields.size()); + assertEquals("a", fields.get(0)); + assertEquals("b", fields.get(1)); + } + + @Test + void getFieldName() + { + Table t = new Table(); + t.addField("first"); + t.addField("second"); + assertEquals("first", t.getFieldName(0)); + assertEquals("second", t.getFieldName(1)); + } + + @Test + void getFieldIndex() + { + Table t = new Table(); + t.addField("a"); + t.addField("b"); + assertEquals(0, t.getFieldIndex("a")); + assertEquals(1, t.getFieldIndex("b")); + } + + // ===== getRecord ===== + + @Test + void getRecord() + { + Table t = new Table(); + t.addField("name"); + t.addField("value"); + Record r = new Record().with("name", "foo").with("value", "bar"); + t.addRecord(r); + + Integer key = t.getKeys().get(0); + Record retrieved = t.getRecord(key); + assertEquals("foo", retrieved.get("name")); + assertEquals("bar", retrieved.get("value")); + } + + @Test + void getRecords() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + t.addRecord(new String[]{"2"}); + List records = t.getRecords(); + assertEquals(2, records.size()); + } + + // ===== getFieldValues ===== + + @Test + void getFieldValues() + { + Table t = new Table(); + t.addField("name"); + t.addRecord(new String[]{"alice"}); + t.addRecord(new String[]{"bob"}); + t.addRecord(new String[]{"charlie"}); + + List values = t.getFieldValues("name"); + assertEquals(3, values.size()); + assertTrue(values.contains("alice")); + assertTrue(values.contains("bob")); + assertTrue(values.contains("charlie")); + } + + // ===== remove ===== + + @Test + void remove() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + t.addRecord(new String[]{"2"}); + assertEquals(2, t.getNumRecords()); + + Integer key = t.getKeys().get(0); + t.remove(key); + assertEquals(1, t.getNumRecords()); + } + + // ===== hasRow ===== + + @Test + void hasRow() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + Integer key = t.getKeys().get(0); + assertTrue(t.hasRow(key)); + assertFalse(t.hasRow(999)); + } + + // ===== where ===== + + @Test + void where() + { + Table t = new Table(); + t.addField("name"); + t.addField("color"); + t.addRecord(new Record().with("name", "a").with("color", "red")); + t.addRecord(new Record().with("name", "b").with("color", "blue")); + t.addRecord(new Record().with("name", "c").with("color", "red")); + + Record query = new Record().with("color", "red"); + List results = t.where(query); + assertEquals(2, results.size()); + } + + // ===== withField with defaults ===== + + @Test + void withFieldDefault() + { + Table t = new Table(); + t.addField("name"); + t.addRecord(new String[]{"alice"}); + + // adding a new field with default should expand existing rows + t.withField("status", "active"); + assertEquals(2, t.getNumFields()); + Integer key = t.getKeys().get(0); + assertEquals("active", t.get(key, "status")); + } + + // ===== copy ===== + + @Test + void copy() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + t.addRecord(new String[]{"2"}); + + Table c = t.copy(); + assertEquals(t.getNumRecords(), c.getNumRecords()); + assertEquals(t.getNumFields(), c.getNumFields()); + } + + @Test + void copyIndependence() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + + Table c = t.copy(); + c.addRecord(new String[]{"2"}); + assertEquals(1, t.getNumRecords()); + assertEquals(2, c.getNumRecords()); + } + + // ===== proto ===== + + @Test + void proto() + { + Table t = new Table(); + t.addField("a"); + t.addField("b"); + t.addRecord(new String[]{"1", "2"}); + + Table p = t.proto(); + assertEquals(2, p.getNumFields()); + assertEquals(0, p.getNumRecords()); + } + + // ===== schema ===== + + @Test + void getSchema() + { + Table t = new Table(); + t.addField("name"); + t.addField("value"); + Schema s = t.getSchema(); + assertEquals(2, s.size()); + assertTrue(s.hasField("name")); + assertTrue(s.locked()); // getSchema returns a locked copy + } + + // ===== iterator ===== + + @Test + void iterator() + { + Table t = new Table(); + t.addField("x"); + t.addRecord(new String[]{"1"}); + t.addRecord(new String[]{"2"}); + t.addRecord(new String[]{"3"}); + + int count = 0; + for (String[] row : t) + { + count++; + } + assertEquals(3, count); + } +} diff --git a/test/qit/data/datasets/VectTest.java b/test/qit/data/datasets/VectTest.java new file mode 100644 index 0000000..400eed0 --- /dev/null +++ b/test/qit/data/datasets/VectTest.java @@ -0,0 +1,357 @@ +package qit.data.datasets; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class VectTest +{ + private static final double EPS = 1e-9; + + // ===== construction ===== + + @Test + void constructWithSize() + { + Vect v = new Vect(3); + assertEquals(3, v.size()); + assertEquals(0.0, v.get(0), EPS); + assertEquals(0.0, v.get(1), EPS); + assertEquals(0.0, v.get(2), EPS); + } + + @Test + void constructWithArray() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0}); + assertEquals(3, v.size()); + assertEquals(1.0, v.get(0), EPS); + assertEquals(2.0, v.get(1), EPS); + assertEquals(3.0, v.get(2), EPS); + } + + // ===== accessors ===== + + @Test + void setAndGet() + { + Vect v = new Vect(3); + v.set(0, 5.0); + v.set(1, 10.0); + v.set(2, 15.0); + assertEquals(5.0, v.get(0), EPS); + assertEquals(10.0, v.get(1), EPS); + assertEquals(15.0, v.get(2), EPS); + } + + @Test + void xyzAccessors() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0}); + assertEquals(1.0, v.getX(), EPS); + assertEquals(2.0, v.getY(), EPS); + assertEquals(3.0, v.getZ(), EPS); + } + + // ===== arithmetic (return new Vect) ===== + + @Test + void plusScalar() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0}); + Vect result = v.plus(10.0); + assertEquals(11.0, result.get(0), EPS); + assertEquals(12.0, result.get(1), EPS); + assertEquals(13.0, result.get(2), EPS); + // original unchanged + assertEquals(1.0, v.get(0), EPS); + } + + @Test + void plusVect() + { + Vect a = new Vect(new double[]{1.0, 2.0, 3.0}); + Vect b = new Vect(new double[]{4.0, 5.0, 6.0}); + Vect result = a.plus(b); + assertEquals(5.0, result.get(0), EPS); + assertEquals(7.0, result.get(1), EPS); + assertEquals(9.0, result.get(2), EPS); + } + + @Test + void minusScalar() + { + Vect v = new Vect(new double[]{10.0, 20.0, 30.0}); + Vect result = v.minus(5.0); + assertEquals(5.0, result.get(0), EPS); + assertEquals(15.0, result.get(1), EPS); + assertEquals(25.0, result.get(2), EPS); + } + + @Test + void minusVect() + { + Vect a = new Vect(new double[]{5.0, 8.0, 12.0}); + Vect b = new Vect(new double[]{1.0, 3.0, 7.0}); + Vect result = a.minus(b); + assertEquals(4.0, result.get(0), EPS); + assertEquals(5.0, result.get(1), EPS); + assertEquals(5.0, result.get(2), EPS); + } + + @Test + void timesScalar() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0}); + Vect result = v.times(3.0); + assertEquals(3.0, result.get(0), EPS); + assertEquals(6.0, result.get(1), EPS); + assertEquals(9.0, result.get(2), EPS); + } + + @Test + void divScalar() + { + Vect v = new Vect(new double[]{10.0, 20.0, 30.0}); + Vect result = v.div(5.0); + assertEquals(2.0, result.get(0), EPS); + assertEquals(4.0, result.get(1), EPS); + assertEquals(6.0, result.get(2), EPS); + } + + // ===== geometry ===== + + @Test + void dotProduct() + { + Vect a = new Vect(new double[]{1.0, 2.0, 3.0}); + Vect b = new Vect(new double[]{4.0, 5.0, 6.0}); + assertEquals(32.0, a.dot(b), EPS); + } + + @Test + void crossProduct() + { + Vect x = new Vect(new double[]{1.0, 0.0, 0.0}); + Vect y = new Vect(new double[]{0.0, 1.0, 0.0}); + Vect z = x.cross(y); + assertEquals(0.0, z.get(0), EPS); + assertEquals(0.0, z.get(1), EPS); + assertEquals(1.0, z.get(2), EPS); + } + + @Test + void crossProductAntiCommutative() + { + Vect a = new Vect(new double[]{1.0, 2.0, 3.0}); + Vect b = new Vect(new double[]{4.0, 5.0, 6.0}); + Vect ab = a.cross(b); + Vect ba = b.cross(a); + assertEquals(-ab.get(0), ba.get(0), EPS); + assertEquals(-ab.get(1), ba.get(1), EPS); + assertEquals(-ab.get(2), ba.get(2), EPS); + } + + @Test + void norm() + { + Vect v = new Vect(new double[]{3.0, 4.0}); + assertEquals(5.0, v.norm(), EPS); + } + + @Test + void norm2() + { + Vect v = new Vect(new double[]{3.0, 4.0}); + assertEquals(25.0, v.norm2(), EPS); + } + + @Test + void norm1() + { + Vect v = new Vect(new double[]{-3.0, 4.0, -2.0}); + assertEquals(9.0, v.norm1(), EPS); + } + + @Test + void dist() + { + Vect a = new Vect(new double[]{0.0, 0.0}); + Vect b = new Vect(new double[]{3.0, 4.0}); + assertEquals(5.0, a.dist(b), EPS); + } + + @Test + void normalize() + { + Vect v = new Vect(new double[]{3.0, 4.0, 0.0}); + Vect n = v.normalize(); + assertEquals(1.0, n.norm(), 1e-6); + assertEquals(0.6, n.get(0), 1e-6); + assertEquals(0.8, n.get(1), 1e-6); + } + + @Test + void angleDeg() + { + Vect x = new Vect(new double[]{1.0, 0.0, 0.0}); + Vect y = new Vect(new double[]{0.0, 1.0, 0.0}); + assertEquals(90.0, x.angleDeg(y), 1e-6); + } + + @Test + void angleRad() + { + Vect a = new Vect(new double[]{1.0, 0.0, 0.0}); + Vect b = new Vect(new double[]{1.0, 0.0, 0.0}); + assertEquals(0.0, a.angleRad(b), 1e-6); + } + + // ===== statistics ===== + + @Test + void sum() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0, 4.0}); + assertEquals(10.0, v.sum(), EPS); + } + + @Test + void mean() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0, 4.0}); + assertEquals(2.5, v.mean(), EPS); + } + + @Test + void min() + { + Vect v = new Vect(new double[]{3.0, 1.0, 5.0, 2.0}); + assertEquals(1.0, v.min(), EPS); + } + + @Test + void max() + { + Vect v = new Vect(new double[]{3.0, 1.0, 5.0, 2.0}); + assertEquals(5.0, v.max(), EPS); + } + + // ===== element-wise transforms ===== + + @Test + void abs() + { + Vect v = new Vect(new double[]{-1.0, 2.0, -3.0}); + Vect result = v.abs(); + assertEquals(1.0, result.get(0), EPS); + assertEquals(2.0, result.get(1), EPS); + assertEquals(3.0, result.get(2), EPS); + } + + @Test + void sq() + { + Vect v = new Vect(new double[]{2.0, 3.0, 4.0}); + Vect result = v.sq(); + assertEquals(4.0, result.get(0), EPS); + assertEquals(9.0, result.get(1), EPS); + assertEquals(16.0, result.get(2), EPS); + } + + @Test + void sqrtVect() + { + Vect v = new Vect(new double[]{4.0, 9.0, 16.0}); + Vect result = v.sqrt(); + assertEquals(2.0, result.get(0), EPS); + assertEquals(3.0, result.get(1), EPS); + assertEquals(4.0, result.get(2), EPS); + } + + @Test + void expAndLog() + { + Vect v = new Vect(new double[]{0.0, 1.0, 2.0}); + Vect e = v.exp(); + assertEquals(1.0, e.get(0), EPS); + assertEquals(Math.E, e.get(1), 1e-6); + + // log(exp(x)) == x + Vect roundtrip = e.log(); + assertEquals(0.0, roundtrip.get(0), 1e-6); + assertEquals(1.0, roundtrip.get(1), 1e-6); + assertEquals(2.0, roundtrip.get(2), 1e-6); + } + + // ===== sub / cat ===== + + @Test + void sub() + { + Vect v = new Vect(new double[]{10.0, 20.0, 30.0, 40.0, 50.0}); + Vect s = v.sub(1, 4); + assertEquals(3, s.size()); + assertEquals(20.0, s.get(0), EPS); + assertEquals(30.0, s.get(1), EPS); + assertEquals(40.0, s.get(2), EPS); + } + + @Test + void cat() + { + Vect a = new Vect(new double[]{1.0, 2.0}); + Vect b = new Vect(new double[]{3.0, 4.0}); + Vect c = a.cat(b); + assertEquals(4, c.size()); + assertEquals(1.0, c.get(0), EPS); + assertEquals(2.0, c.get(1), EPS); + assertEquals(3.0, c.get(2), EPS); + assertEquals(4.0, c.get(3), EPS); + } + + // ===== predicates ===== + + @Test + void finite() + { + Vect good = new Vect(new double[]{1.0, 2.0, 3.0}); + assertTrue(good.finite()); + + Vect bad = new Vect(new double[]{1.0, Double.NaN, 3.0}); + assertFalse(bad.finite()); + + Vect inf = new Vect(new double[]{1.0, Double.POSITIVE_INFINITY, 3.0}); + assertFalse(inf.finite()); + } + + // ===== copy independence ===== + + @Test + void copy() + { + Vect v = new Vect(new double[]{1.0, 2.0, 3.0}); + Vect c = v.copy(); + assertEquals(v.get(0), c.get(0), EPS); + c.set(0, 999.0); + assertEquals(1.0, v.get(0), EPS); // original unchanged + } + + // ===== prod / cumsum ===== + + @Test + void prod() + { + Vect v = new Vect(new double[]{2.0, 3.0, 4.0}); + assertEquals(24.0, v.prod(), EPS); + } + + // ===== zero vector ===== + + @Test + void zeroVector() + { + Vect v = new Vect(4); + assertEquals(0.0, v.norm(), EPS); + assertEquals(0.0, v.sum(), EPS); + } +} diff --git a/test/qit/math/structs/BoxTest.java b/test/qit/math/structs/BoxTest.java new file mode 100644 index 0000000..4a112de --- /dev/null +++ b/test/qit/math/structs/BoxTest.java @@ -0,0 +1,250 @@ +package qit.math.structs; + +import org.junit.jupiter.api.Test; +import qit.data.datasets.Vect; + +import static org.junit.jupiter.api.Assertions.*; + +class BoxTest +{ + private static final double EPS = 1e-9; + + private Box box3D(double minX, double maxX, double minY, double maxY, double minZ, double maxZ) + { + return new Box(new Interval[]{ + new Interval(minX, maxX), + new Interval(minY, maxY), + new Interval(minZ, maxZ) + }); + } + + // ===== creation ===== + + @Test + void createFromPoint() + { + Vect p = new Vect(new double[]{1.0, 2.0, 3.0}); + Box b = Box.create(p); + assertEquals(3, b.dim()); + assertEquals(1.0, b.getMin().get(0), EPS); + assertEquals(1.0, b.getMax().get(0), EPS); + assertEquals(2.0, b.getMin().get(1), EPS); + assertEquals(3.0, b.getMax().get(2), EPS); + } + + @Test + void createUnionTwoPoints() + { + Vect a = new Vect(new double[]{0, 0, 0}); + Vect b = new Vect(new double[]{1, 2, 3}); + Box box = Box.createUnion(a, b); + assertEquals(0.0, box.getMin().get(0), EPS); + assertEquals(1.0, box.getMax().get(0), EPS); + assertEquals(0.0, box.getMin().get(1), EPS); + assertEquals(2.0, box.getMax().get(1), EPS); + assertEquals(3.0, box.getMax().get(2), EPS); + } + + @Test + void createUnionThreePoints() + { + Vect a = new Vect(new double[]{0, 5, 0}); + Vect b = new Vect(new double[]{3, 0, 0}); + Vect c = new Vect(new double[]{0, 0, 7}); + Box box = Box.createUnion(a, b, c); + assertEquals(0.0, box.getMin().get(0), EPS); + assertEquals(3.0, box.getMax().get(0), EPS); + assertEquals(0.0, box.getMin().get(1), EPS); + assertEquals(5.0, box.getMax().get(1), EPS); + assertEquals(7.0, box.getMax().get(2), EPS); + } + + @Test + void createRadius() + { + Vect center = new Vect(new double[]{5, 5, 5}); + Box b = Box.createRadius(center, 2.0); + assertEquals(3.0, b.getMin().get(0), EPS); + assertEquals(7.0, b.getMax().get(0), EPS); + } + + // ===== accessors ===== + + @Test + void dim() + { + Box b = box3D(0, 1, 0, 1, 0, 1); + assertEquals(3, b.dim()); + } + + @Test + void getCenter() + { + Box b = box3D(0, 10, 0, 20, 0, 30); + Vect center = b.getCenter(); + assertEquals(5.0, center.get(0), EPS); + assertEquals(10.0, center.get(1), EPS); + assertEquals(15.0, center.get(2), EPS); + } + + @Test + void getDiameter() + { + // unit cube: diameter = sqrt(3) + Box b = box3D(0, 1, 0, 1, 0, 1); + assertEquals(Math.sqrt(3.0), b.getDiameter(), EPS); + } + + @Test + void getInterval() + { + Box b = box3D(1, 5, 2, 8, 3, 9); + Interval i = b.getInterval(0); + assertEquals(1.0, i.getMin(), EPS); + assertEquals(5.0, i.getMax(), EPS); + } + + // ===== contains ===== + + @Test + void containsPoint() + { + Box b = box3D(0, 10, 0, 10, 0, 10); + assertTrue(b.contains(new Vect(new double[]{5, 5, 5}))); + assertTrue(b.contains(new Vect(new double[]{0, 0, 0}))); // boundary + assertTrue(b.contains(new Vect(new double[]{10, 10, 10}))); // boundary + assertFalse(b.contains(new Vect(new double[]{11, 5, 5}))); + assertFalse(b.contains(new Vect(new double[]{5, -1, 5}))); + } + + @Test + void containsBox() + { + Box outer = box3D(0, 10, 0, 10, 0, 10); + Box inner = box3D(2, 8, 2, 8, 2, 8); + Box overlapping = box3D(5, 15, 5, 15, 5, 15); + assertTrue(outer.contains(inner)); + assertTrue(outer.contains(outer)); + assertFalse(outer.contains(overlapping)); + assertFalse(inner.contains(outer)); + } + + // ===== intersects ===== + + @Test + void intersects() + { + Box a = box3D(0, 5, 0, 5, 0, 5); + Box b = box3D(3, 8, 3, 8, 3, 8); + Box c = box3D(6, 9, 6, 9, 6, 9); + assertTrue(a.intersects(b)); + assertTrue(b.intersects(a)); + assertFalse(a.intersects(c)); + assertFalse(c.intersects(a)); + } + + @Test + void intersectsTouching() + { + Box a = box3D(0, 5, 0, 5, 0, 5); + Box b = box3D(5, 10, 5, 10, 5, 10); + assertTrue(a.intersects(b)); // touching at corner + } + + // ===== union ===== + + @Test + void unionBox() + { + Box a = box3D(0, 5, 0, 5, 0, 5); + Box b = box3D(3, 10, 3, 10, 3, 10); + Box u = a.union(b); + assertEquals(0.0, u.getMin().get(0), EPS); + assertEquals(10.0, u.getMax().get(0), EPS); + } + + @Test + void unionVect() + { + Box b = box3D(0, 5, 0, 5, 0, 5); + Box expanded = b.union(new Vect(new double[]{10, 10, 10})); + assertEquals(10.0, expanded.getMax().get(0), EPS); + assertEquals(0.0, expanded.getMin().get(0), EPS); + } + + // ===== grow / scale / shift ===== + + @Test + void grow() + { + Box b = box3D(2, 8, 2, 8, 2, 8); + Box grown = b.grow(1.0); + assertEquals(1.0, grown.getMin().get(0), EPS); + assertEquals(9.0, grown.getMax().get(0), EPS); + } + + @Test + void scale() + { + Box b = box3D(0, 10, 0, 10, 0, 10); + Box scaled = b.scale(2.0); + // size doubles from 10 to 20, so min becomes -5, max becomes 15 + assertEquals(-5.0, scaled.getMin().get(0), EPS); + assertEquals(15.0, scaled.getMax().get(0), EPS); + } + + @Test + void shift() + { + Box b = box3D(0, 5, 0, 5, 0, 5); + Box shifted = b.shift(new Vect(new double[]{10, 20, 30})); + assertEquals(10.0, shifted.getMin().get(0), EPS); + assertEquals(15.0, shifted.getMax().get(0), EPS); + assertEquals(20.0, shifted.getMin().get(1), EPS); + assertEquals(25.0, shifted.getMax().get(1), EPS); + } + + @Test + void buffer() + { + Box b = box3D(2, 8, 2, 8, 2, 8); + Box buffered = b.buffer(1.0); + assertEquals(1.0, buffered.getMin().get(0), EPS); + assertEquals(9.0, buffered.getMax().get(0), EPS); + } + + // ===== copy ===== + + @Test + void copy() + { + Box b = box3D(1, 5, 2, 8, 3, 9); + Box c = b.copy(); + assertEquals(b.getMin().get(0), c.getMin().get(0), EPS); + assertEquals(b.getMax().get(2), c.getMax().get(2), EPS); + } + + // ===== immutability ===== + + @Test + void immutability() + { + Box b = box3D(0, 10, 0, 10, 0, 10); + b.grow(5); + b.scale(2.0); + b.union(new Vect(new double[]{100, 100, 100})); + // all methods return new Box, original unchanged + assertEquals(0.0, b.getMin().get(0), EPS); + assertEquals(10.0, b.getMax().get(0), EPS); + } + + // ===== label ===== + + @Test + void label() + { + Box b = box3D(0, 10, 0, 10, 0, 10); + assertEquals(1, b.label(new Vect(new double[]{5, 5, 5}))); + assertEquals(0, b.label(new Vect(new double[]{15, 5, 5}))); + } +} diff --git a/test/qit/math/structs/HistogramTest.java b/test/qit/math/structs/HistogramTest.java new file mode 100644 index 0000000..9b1fe69 --- /dev/null +++ b/test/qit/math/structs/HistogramTest.java @@ -0,0 +1,268 @@ +package qit.math.structs; + +import org.junit.jupiter.api.Test; +import qit.data.datasets.Vect; + +import static org.junit.jupiter.api.Assertions.*; + +class HistogramTest +{ + private static final double EPS = 1e-9; + + // ===== creation ===== + + @Test + void createBasic() + { + Histogram h = Histogram.create(10, 0.0, 1.0); + assertEquals(10, h.size()); + assertEquals(10, h.getBins()); + } + + @Test + void createBreakBounds() + { + Histogram h = Histogram.create(5, 0.0, 10.0); + assertEquals(0.0, h.getBoundBelow(0), EPS); + assertEquals(10.0, h.getBoundAbove(h.size() - 1), EPS); + } + + // ===== update / contains ===== + + @Test + void updateAndGet() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + h.update(5.0); + h.update(5.0); + h.update(5.0); + assertEquals(3.0, h.sum(), EPS); + } + + @Test + void updateOutOfRange() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + h.update(-1.0); // outside range, should be ignored + h.update(11.0); // outside range, should be ignored + assertEquals(0.0, h.sum(), EPS); + } + + @Test + void containsValue() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + assertTrue(h.contains(0.0)); + assertTrue(h.contains(5.0)); + assertTrue(h.contains(10.0)); + assertFalse(h.contains(-0.1)); + assertFalse(h.contains(10.1)); + } + + // ===== sum ===== + + @Test + void sum() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + for (int i = 0; i < 100; i++) + { + h.update(i * 0.1); + } + assertEquals(100.0, h.sum(), EPS); + } + + // ===== maxIndex / maxValue ===== + + @Test + void maxIndexAndValue() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + // put most samples in one bin + h.update(1.0); + h.update(1.5); + h.update(1.5); + h.update(1.5); + h.update(5.0); + + assertEquals(h.get(h.maxIndex()), h.maxValue(), EPS); + assertTrue(h.maxValue() >= 3.0); + } + + // ===== mode ===== + + @Test + void mode() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + // concentrate values around 5 + for (int i = 0; i < 50; i++) + { + h.update(5.0); + } + h.update(1.0); + // mode should be near 5.0 + double m = h.mode(); + assertTrue(m > 4.0 && m < 6.0); + } + + // ===== normalize ===== + + @Test + void normalize() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + h.update(1.0); + h.update(3.0); + h.update(5.0); + h.update(7.0); + h.update(9.0); + h.normalize(); + assertEquals(1.0, h.sum(), 1e-6); + } + + // ===== density ===== + + @Test + void density() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + h.update(1.0); + h.update(3.0); + h.update(5.0); + Vect d = h.density(); + assertEquals(h.size(), d.size()); + assertEquals(1.0, d.sum(), 1e-6); + } + + // ===== entropy ===== + + @Test + void entropyUniform() + { + // uniform distribution should have high entropy + Histogram h = Histogram.create(10, 0.0, 10.0); + for (int i = 0; i < 10; i++) + { + h.update(i + 0.5); + } + double entropy = h.entropy(); + assertTrue(entropy > 0); + } + + @Test + void entropyConcentrated() + { + // concentrated distribution has lower entropy than uniform + Histogram uniform = Histogram.create(10, 0.0, 10.0); + for (int i = 0; i < 10; i++) + { + uniform.update(i + 0.5); + } + + Histogram concentrated = Histogram.create(10, 0.0, 10.0); + for (int i = 0; i < 10; i++) + { + concentrated.update(5.0); + } + + assertTrue(concentrated.entropy() < uniform.entropy()); + } + + // ===== otsu ===== + + @Test + void otsuBimodal() + { + // bimodal distribution with peaks at low and high ends + Histogram h = Histogram.create(100, 0.0, 100.0); + // low cluster around 20 + for (int i = 0; i < 50; i++) + { + h.update(20.0); + } + // high cluster around 80 + for (int i = 0; i < 50; i++) + { + h.update(80.0); + } + + double threshold = h.otsu(); + // otsu threshold should fall between the two clusters + assertTrue(threshold > 20.0 && threshold < 80.0, + "Otsu threshold " + threshold + " should be between 20 and 80"); + } + + // ===== compatible ===== + + @Test + void compatible() + { + Histogram a = Histogram.create(10, 0.0, 10.0); + Histogram b = Histogram.create(10, 0.0, 10.0); + Histogram c = Histogram.create(20, 0.0, 10.0); + assertTrue(a.compatible(b)); + assertFalse(a.compatible(c)); + } + + // ===== vect ===== + + @Test + void vect() + { + Histogram h = Histogram.create(5, 0.0, 5.0); + h.update(1.0); + h.update(3.0); + Vect v = h.vect(); + assertEquals(5, v.size()); + assertEquals(2.0, v.sum(), EPS); + } + + // ===== copy ===== + + @Test + void copy() + { + Histogram h = Histogram.create(10, 0.0, 10.0); + h.update(5.0); + h.update(5.0); + Histogram c = h.copy(); + assertEquals(h.sum(), c.sum(), EPS); + assertEquals(h.size(), c.size()); + + // modify copy, original unchanged + c.update(5.0); + assertEquals(2.0, h.sum(), EPS); + assertEquals(3.0, c.sum(), EPS); + } + + // ===== table conversion ===== + + @Test + void table() + { + Histogram h = Histogram.create(5, 0.0, 5.0); + h.update(1.0); + qit.data.datasets.Table t = h.table(); + assertEquals(5, t.getNumRecords()); + assertTrue(t.hasField("index")); + assertTrue(t.hasField("below")); + assertTrue(t.hasField("middle")); + assertTrue(t.hasField("above")); + assertTrue(t.hasField("value")); + } + + // ===== smoothBins ===== + + @Test + void smoothBinsPreservesTotal() + { + Histogram h = Histogram.create(20, 0.0, 20.0); + h.update(10.0); + h.update(10.0); + h.update(10.0); + double sumBefore = h.sum(); + h.smoothBins(2.0); + assertEquals(sumBefore, h.sum(), 1e-4); + } +} diff --git a/test/qit/math/structs/IntervalTest.java b/test/qit/math/structs/IntervalTest.java new file mode 100644 index 0000000..b858466 --- /dev/null +++ b/test/qit/math/structs/IntervalTest.java @@ -0,0 +1,161 @@ +package qit.math.structs; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class IntervalTest +{ + private static final double EPS = 1e-9; + + @Test + void constructBasic() + { + Interval i = new Interval(1.0, 5.0); + assertEquals(1.0, i.getMin(), EPS); + assertEquals(5.0, i.getMax(), EPS); + } + + @Test + void constructReversedThrows() + { + // constructor asserts min < max || eq(min, max), so reversed args throw + assertThrows(RuntimeException.class, () -> new Interval(5.0, 1.0)); + } + + @Test + void constructDegenerate() + { + Interval i = new Interval(3.0, 3.0); + assertEquals(3.0, i.getMin(), EPS); + assertEquals(3.0, i.getMax(), EPS); + assertEquals(0.0, i.delta(), EPS); + } + + @Test + void delta() + { + Interval i = new Interval(2.0, 7.0); + assertEquals(5.0, i.delta(), EPS); + } + + @Test + void size() + { + Interval i = new Interval(2.0, 7.0); + assertEquals(5.0, i.size(), EPS); + } + + @Test + void getHalf() + { + Interval i = new Interval(2.0, 8.0); + assertEquals(5.0, i.getHalf(), EPS); + } + + @Test + void containsValue() + { + Interval i = new Interval(0.0, 10.0); + assertTrue(i.contains(5.0)); + assertTrue(i.contains(0.0)); // boundary + assertTrue(i.contains(10.0)); // boundary + assertFalse(i.contains(-0.1)); + assertFalse(i.contains(10.1)); + } + + @Test + void containsInterval() + { + Interval outer = new Interval(0.0, 10.0); + Interval inner = new Interval(2.0, 8.0); + Interval overlapping = new Interval(5.0, 15.0); + + assertTrue(outer.contains(inner)); + assertTrue(outer.contains(outer)); // self-containment + assertFalse(outer.contains(overlapping)); + assertFalse(inner.contains(outer)); + } + + @Test + void intersects() + { + Interval a = new Interval(0.0, 5.0); + Interval b = new Interval(3.0, 8.0); + Interval c = new Interval(6.0, 9.0); + Interval d = new Interval(5.0, 7.0); // touching at boundary + + assertTrue(a.intersects(b)); + assertTrue(b.intersects(a)); + assertFalse(a.intersects(c)); + assertFalse(c.intersects(a)); + assertTrue(a.intersects(d)); // touching counts as intersecting + } + + @Test + void unionInterval() + { + Interval a = new Interval(1.0, 5.0); + Interval b = new Interval(3.0, 8.0); + Interval u = a.union(b); + + assertEquals(1.0, u.getMin(), EPS); + assertEquals(8.0, u.getMax(), EPS); + } + + @Test + void unionValue() + { + Interval i = new Interval(2.0, 6.0); + + Interval expanded = i.union(10.0); + assertEquals(2.0, expanded.getMin(), EPS); + assertEquals(10.0, expanded.getMax(), EPS); + + Interval shrunkSide = i.union(-1.0); + assertEquals(-1.0, shrunkSide.getMin(), EPS); + assertEquals(6.0, shrunkSide.getMax(), EPS); + + Interval same = i.union(4.0); // value inside interval + assertEquals(2.0, same.getMin(), EPS); + assertEquals(6.0, same.getMax(), EPS); + } + + @Test + void grow() + { + Interval i = new Interval(2.0, 8.0); + Interval grown = i.grow(1.0); + assertEquals(1.0, grown.getMin(), EPS); + assertEquals(9.0, grown.getMax(), EPS); + } + + @Test + void shift() + { + Interval i = new Interval(2.0, 8.0); + Interval shifted = i.shift(3.0); + assertEquals(5.0, shifted.getMin(), EPS); + assertEquals(11.0, shifted.getMax(), EPS); + } + + @Test + void copy() + { + Interval i = new Interval(1.0, 5.0); + Interval c = i.copy(); + assertEquals(i.getMin(), c.getMin(), EPS); + assertEquals(i.getMax(), c.getMax(), EPS); + } + + @Test + void immutability() + { + Interval i = new Interval(2.0, 8.0); + i.grow(5.0); + i.shift(10.0); + i.union(100.0); + // original should be unchanged since all methods return new instances + assertEquals(2.0, i.getMin(), EPS); + assertEquals(8.0, i.getMax(), EPS); + } +} diff --git a/test/qit/math/structs/QuaternionTest.java b/test/qit/math/structs/QuaternionTest.java new file mode 100644 index 0000000..b57e932 --- /dev/null +++ b/test/qit/math/structs/QuaternionTest.java @@ -0,0 +1,223 @@ +package qit.math.structs; + +import org.junit.jupiter.api.Test; +import qit.data.datasets.Matrix; +import qit.data.datasets.Vect; + +import static org.junit.jupiter.api.Assertions.*; + +class QuaternionTest +{ + private static final double EPS = 1e-6; + + // ===== construction ===== + + @Test + void constructIdentity() + { + // b=c=d=0 => identity rotation (a = sqrt(1-0) = 1) + Quaternion q = new Quaternion(0, 0, 0); + assertEquals(1.0, q.getA(), EPS); + assertEquals(0.0, q.getB(), EPS); + assertEquals(0.0, q.getC(), EPS); + assertEquals(0.0, q.getD(), EPS); + } + + @Test + void constructFromAxisAngle() + { + // 0-degree rotation about any axis => identity + Vect zAxis = new Vect(new double[]{0, 0, 1}); + Quaternion q = new Quaternion(zAxis, 0.0); + assertEquals(1.0, q.getA(), EPS); + assertEquals(0.0, q.getB(), EPS); + assertEquals(0.0, q.getC(), EPS); + assertEquals(0.0, q.getD(), EPS); + } + + @Test + void constructFromAxisAngle90Z() + { + // 90-degree rotation about Z axis + Vect zAxis = new Vect(new double[]{0, 0, 1}); + Quaternion q = new Quaternion(zAxis, Math.PI / 2); + assertEquals(Math.cos(Math.PI / 4), q.getA(), EPS); + assertEquals(0.0, q.getB(), EPS); + assertEquals(0.0, q.getC(), EPS); + assertEquals(Math.sin(Math.PI / 4), q.getD(), EPS); + } + + @Test + void constructFromAxisAngle180() + { + // 180-degree rotation about X axis + Vect xAxis = new Vect(new double[]{1, 0, 0}); + Quaternion q = new Quaternion(xAxis, Math.PI); + assertEquals(0.0, q.getA(), EPS); + assertEquals(1.0, Math.abs(q.getB()), EPS); // could be +/- 1 + } + + // ===== accessors ===== + + @Test + void getADerived() + { + // a = sqrt(1 - b^2 - c^2 - d^2) for unit quaternion + Quaternion q = new Quaternion(0.5, 0.5, 0.0); + double expected = Math.sqrt(1.0 - 0.25 - 0.25); + assertEquals(expected, q.getA(), EPS); + } + + // ===== inverse ===== + + @Test + void inverse() + { + Quaternion q = new Quaternion(0.1, 0.2, 0.3); + Quaternion inv = q.inverse(); + assertEquals(-q.getB(), inv.getB(), EPS); + assertEquals(-q.getC(), inv.getC(), EPS); + assertEquals(-q.getD(), inv.getD(), EPS); + } + + @Test + void inverseRotationIsIdentity() + { + // q * q^-1 applied as rotation should yield identity matrix + Vect axis = new Vect(new double[]{1, 1, 1}); + Quaternion q = new Quaternion(axis, Math.PI / 3); + Matrix rot = q.matrix(); + Matrix invRot = q.inverse().matrix(); + Matrix product = rot.times(invRot); + + // should be close to identity + for (int i = 0; i < 3; i++) + { + for (int j = 0; j < 3; j++) + { + double expected = (i == j) ? 1.0 : 0.0; + assertEquals(expected, product.get(i, j), EPS); + } + } + } + + // ===== matrix conversion ===== + + @Test + void identityMatrix() + { + Quaternion id = new Quaternion(0, 0, 0); + Matrix m = id.matrix(); + assertEquals(3, m.rows()); + assertEquals(3, m.cols()); + for (int i = 0; i < 3; i++) + { + for (int j = 0; j < 3; j++) + { + double expected = (i == j) ? 1.0 : 0.0; + assertEquals(expected, m.get(i, j), EPS); + } + } + } + + @Test + void rotationMatrixIsOrthogonal() + { + // R * R^T = I for any rotation matrix + Vect axis = new Vect(new double[]{1, 2, 3}); + Quaternion q = new Quaternion(axis, 1.23); + Matrix r = q.matrix(); + Matrix product = r.times(r.transpose()); + + for (int i = 0; i < 3; i++) + { + for (int j = 0; j < 3; j++) + { + double expected = (i == j) ? 1.0 : 0.0; + assertEquals(expected, product.get(i, j), EPS); + } + } + } + + @Test + void rotationMatrixDetIsOne() + { + Vect axis = new Vect(new double[]{0, 1, 0}); + Quaternion q = new Quaternion(axis, Math.PI / 6); + Matrix r = q.matrix(); + assertEquals(1.0, r.det(), EPS); + } + + @Test + void matrix90ZRotatesXtoY() + { + // 90-degree rotation about Z: [1,0,0] -> [0,1,0] + Vect zAxis = new Vect(new double[]{0, 0, 1}); + Quaternion q = new Quaternion(zAxis, Math.PI / 2); + Matrix r = q.matrix(); + + Vect x = new Vect(new double[]{1, 0, 0}); + Vect result = r.times(x); + assertEquals(0.0, result.get(0), EPS); + assertEquals(1.0, result.get(1), EPS); + assertEquals(0.0, result.get(2), EPS); + } + + @Test + void matrix4() + { + Quaternion q = new Quaternion(0, 0, 0); + Matrix m4 = q.matrix4(); + assertEquals(4, m4.rows()); + assertEquals(4, m4.cols()); + assertEquals(1.0, m4.get(3, 3), EPS); + assertEquals(0.0, m4.get(3, 0), EPS); + } + + // ===== roundtrip: quaternion -> matrix -> quaternion ===== + + @Test + void matrixToQuaternionRoundtrip() + { + Vect axis = new Vect(new double[]{1, 0, 0}); + double angle = Math.PI / 4; + Quaternion original = new Quaternion(axis, angle); + Matrix rot = original.matrix(); + Quaternion recovered = new Quaternion(rot); + + // rotation matrices should match + Matrix rotRecovered = recovered.matrix(); + for (int i = 0; i < 3; i++) + { + for (int j = 0; j < 3; j++) + { + assertEquals(rot.get(i, j), rotRecovered.get(i, j), EPS); + } + } + } + + // ===== equality ===== + + @Test + void equalsAndHashCode() + { + Quaternion a = new Quaternion(0.1, 0.2, 0.3); + Quaternion b = new Quaternion(0.1, 0.2, 0.3); + Quaternion c = new Quaternion(0.4, 0.5, 0.6); + assertEquals(a, b); + assertNotEquals(a, c); + assertEquals(a, a); + assertNotEquals(a, "not a quaternion"); + } + + // ===== copy ===== + + @Test + void copy() + { + Quaternion q = new Quaternion(0.1, 0.2, 0.3); + Quaternion c = q.copy(); + assertEquals(q, c); + assertEquals(q.getB(), c.getB(), EPS); + } +} diff --git a/test/qit/math/structs/TriangleTest.java b/test/qit/math/structs/TriangleTest.java new file mode 100644 index 0000000..09feec7 --- /dev/null +++ b/test/qit/math/structs/TriangleTest.java @@ -0,0 +1,178 @@ +package qit.math.structs; + +import org.junit.jupiter.api.Test; +import qit.data.datasets.Vect; + +import static org.junit.jupiter.api.Assertions.*; + +class TriangleTest +{ + private static final double EPS = 1e-6; + + private Triangle rightTriangleXY() + { + Vect a = new Vect(new double[]{0, 0, 0}); + Vect b = new Vect(new double[]{1, 0, 0}); + Vect c = new Vect(new double[]{0, 1, 0}); + return new Triangle(a, b, c); + } + + // ===== construction and accessors ===== + + @Test + void vertices() + { + Triangle t = rightTriangleXY(); + assertEquals(0.0, t.getA().get(0), EPS); + assertEquals(1.0, t.getB().get(0), EPS); + assertEquals(1.0, t.getC().get(1), EPS); + } + + @Test + void verticesCopyIndependence() + { + Triangle t = rightTriangleXY(); + Vect a = t.getA(); + a.set(0, 999.0); + assertEquals(0.0, t.getA().get(0), EPS); + } + + // ===== area ===== + + @Test + void areaRightTriangle() + { + // right triangle with legs 1,1: area = 0.5 + Triangle t = rightTriangleXY(); + assertEquals(0.5, t.area(), EPS); + } + + @Test + void areaEquilateral() + { + // equilateral triangle with side 2: area = sqrt(3) + Vect a = new Vect(new double[]{0, 0, 0}); + Vect b = new Vect(new double[]{2, 0, 0}); + Vect c = new Vect(new double[]{1, Math.sqrt(3), 0}); + Triangle t = new Triangle(a, b, c); + assertEquals(Math.sqrt(3), t.area(), EPS); + } + + @Test + void areaDegenerateTriangle() + { + // collinear points => area 0 + Vect a = new Vect(new double[]{0, 0, 0}); + Vect b = new Vect(new double[]{1, 0, 0}); + Vect c = new Vect(new double[]{2, 0, 0}); + Triangle t = new Triangle(a, b, c); + assertEquals(0.0, t.area(), EPS); + } + + // ===== center ===== + + @Test + void center() + { + Triangle t = rightTriangleXY(); + Vect center = t.center(); + assertEquals(1.0 / 3.0, center.get(0), EPS); + assertEquals(1.0 / 3.0, center.get(1), EPS); + assertEquals(0.0, center.get(2), EPS); + } + + // ===== plane / normal ===== + + @Test + void planeNormal() + { + // right triangle in XY plane should have normal along Z + Triangle t = rightTriangleXY(); + Plane p = t.plane(); + // plane equation: ax + by + cz + d = 0 + // for XY plane: z = 0, so normal is (0,0,1) or (0,0,-1) + // a should be ~0, b should be ~0 + // we verify all three vertices lie on the plane + Vect va = t.getA(); + double eval = p.getA() * va.get(0) + p.getB() * va.get(1) + p.getC() * va.get(2) + p.getD(); + assertEquals(0.0, eval, EPS); + } + + // ===== closest point / distance ===== + + @Test + void distAtVertex() + { + Triangle t = rightTriangleXY(); + Vect p = new Vect(new double[]{0, 0, 0}); + assertEquals(0.0, t.dist(p), EPS); + } + + @Test + void distAboveTriangle() + { + // point directly above centroid at height 1 + Triangle t = rightTriangleXY(); + Vect p = new Vect(new double[]{0.2, 0.2, 1.0}); + assertEquals(1.0, t.dist(p), EPS); + } + + @Test + void closestPointOnVertex() + { + Triangle t = rightTriangleXY(); + Vect p = new Vect(new double[]{-1, -1, 0}); + Bary bary = t.closest(p); + // closest vertex should be A (origin) + assertEquals(1.0, bary.getU(), EPS); + assertEquals(0.0, bary.getV(), EPS); + assertEquals(0.0, bary.getW(), EPS); + } + + @Test + void closestPointInside() + { + Triangle t = rightTriangleXY(); + Vect p = new Vect(new double[]{0.2, 0.2, 0.0}); + Bary bary = t.closest(p); + // point is inside triangle, barycentric coords should sum to 1 + assertEquals(1.0, bary.getU() + bary.getV() + bary.getW(), EPS); + assertTrue(bary.getU() > 0); + assertTrue(bary.getV() > 0); + assertTrue(bary.getW() > 0); + } + + @Test + void vectFromBary() + { + Triangle t = rightTriangleXY(); + // barycentric (1,0,0) should give vertex A + Bary baryA = new Bary(1, 0, 0); + Vect va = t.vect(baryA); + assertEquals(0.0, va.get(0), EPS); + assertEquals(0.0, va.get(1), EPS); + + // barycentric (0,1,0) should give vertex B + Bary baryB = new Bary(0, 1, 0); + Vect vb = t.vect(baryB); + assertEquals(1.0, vb.get(0), EPS); + assertEquals(0.0, vb.get(1), EPS); + + // centroid + Bary baryCentroid = new Bary(1.0 / 3, 1.0 / 3, 1.0 / 3); + Vect vc = t.vect(baryCentroid); + assertEquals(1.0 / 3.0, vc.get(0), EPS); + assertEquals(1.0 / 3.0, vc.get(1), EPS); + } + + // ===== copy ===== + + @Test + void copy() + { + Triangle t = rightTriangleXY(); + Triangle c = t.copy(); + assertEquals(t.getA().get(0), c.getA().get(0), EPS); + assertEquals(t.getB().get(0), c.getB().get(0), EPS); + } +} diff --git a/test/qit/math/utils/MathUtilsTest.java b/test/qit/math/utils/MathUtilsTest.java new file mode 100644 index 0000000..1ddb16d --- /dev/null +++ b/test/qit/math/utils/MathUtilsTest.java @@ -0,0 +1,613 @@ +package qit.math.utils; + +import org.junit.jupiter.api.Test; +import qit.base.Global; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class MathUtilsTest +{ + private static final double EPS = 1e-9; + + // ===== equality / comparison ===== + + @Test + void eqWithinDelta() + { + assertTrue(MathUtils.eq(1.0, 1.0)); + assertTrue(MathUtils.eq(1.0, 1.0 + 1e-7)); // within default DELTA=1e-6 + assertFalse(MathUtils.eq(1.0, 1.0 + 1e-5)); // outside DELTA + } + + @Test + void eqCustomThreshold() + { + assertTrue(MathUtils.eq(1.0, 1.5, 0.6)); + assertFalse(MathUtils.eq(1.0, 1.5, 0.4)); + } + + @Test + void zero() + { + assertTrue(MathUtils.zero(0.0)); + assertTrue(MathUtils.zero(1e-7)); + assertFalse(MathUtils.zero(1e-5)); + } + + @Test + void nonzero() + { + assertFalse(MathUtils.nonzero(0.0)); + assertTrue(MathUtils.nonzero(1.0)); + } + + @Test + void unit() + { + assertTrue(MathUtils.unit(1.0)); + assertTrue(MathUtils.unit(1.0 + 1e-7)); + assertFalse(MathUtils.unit(0.5)); + } + + @Test + void valid() + { + assertTrue(MathUtils.valid(0.0)); + assertTrue(MathUtils.valid(1e308)); + assertTrue(MathUtils.valid(-1e308)); + assertFalse(MathUtils.valid(Double.NaN)); + assertFalse(MathUtils.valid(Double.POSITIVE_INFINITY)); + assertFalse(MathUtils.valid(Double.NEGATIVE_INFINITY)); + } + + @Test + void openAndClosed() + { + assertTrue(MathUtils.open(5.0, 0.0, 10.0)); + assertFalse(MathUtils.open(0.0, 0.0, 10.0)); // boundary excluded + assertFalse(MathUtils.open(10.0, 0.0, 10.0)); + + assertTrue(MathUtils.closed(5.0, 0.0, 10.0)); + assertTrue(MathUtils.closed(0.0, 0.0, 10.0)); // boundary included + assertTrue(MathUtils.closed(10.0, 0.0, 10.0)); + assertFalse(MathUtils.closed(-1.0, 0.0, 10.0)); + } + + // ===== sign / rounding ===== + + @Test + void sign() + { + assertEquals(1, MathUtils.sign(5.0)); + assertEquals(-1, MathUtils.sign(-3.0)); + assertEquals(1, MathUtils.sign(0.0)); // sign(0) = 1 + } + + @Test + void signzero() + { + assertEquals(1, MathUtils.signzero(5.0)); + assertEquals(-1, MathUtils.signzero(-3.0)); + assertEquals(0, MathUtils.signzero(0.0)); + } + + @Test + void round() + { + assertEquals(3, MathUtils.round(2.7)); + assertEquals(2, MathUtils.round(2.3)); + // round uses ipart(x+0.5), so for negatives: ipart(-2.7+0.5) = ipart(-2.2) = -2 + assertEquals(-2, MathUtils.round(-2.7)); + } + + @Test + void ipart() + { + assertEquals(3, MathUtils.ipart(3.7)); + assertEquals(-3, MathUtils.ipart(-3.7)); + } + + @Test + void fpart() + { + assertEquals(0.7, MathUtils.fpart(3.7), 1e-6); + } + + // ===== pure math functions ===== + + @Test + void gaussian() + { + // standard normal at mean should give 1/sqrt(2*pi) ~ 0.3989 + double atMean = MathUtils.gaussian(0.0, 0.0, 1.0); + assertEquals(1.0 / Math.sqrt(2 * Math.PI), atMean, 1e-10); + + // symmetric: f(mean+1) == f(mean-1) + double above = MathUtils.gaussian(1.0, 0.0, 1.0); + double below = MathUtils.gaussian(-1.0, 0.0, 1.0); + assertEquals(above, below, 1e-10); + + // wider std -> lower peak + double wide = MathUtils.gaussian(0.0, 0.0, 2.0); + assertTrue(wide < atMean); + } + + @Test + void cubicthresh() + { + assertEquals(0.0, MathUtils.cubicthresh(-1.0), EPS); + assertEquals(0.0, MathUtils.cubicthresh(0.0), EPS); + assertEquals(1.0, MathUtils.cubicthresh(1.0), EPS); + assertEquals(1.0, MathUtils.cubicthresh(2.0), EPS); + assertEquals(0.5, MathUtils.cubicthresh(0.5), EPS); // 3*(0.25) - 2*(0.125) = 0.5 + } + + @Test + void logGamma() + { + // logGamma(1) = log(0!) = 0 + assertEquals(0.0, MathUtils.logGamma(1.0), 1e-6); + // logGamma(2) = log(1!) = 0 + assertEquals(0.0, MathUtils.logGamma(2.0), 1e-6); + // logGamma(6) = log(5!) = log(120) + assertEquals(Math.log(120.0), MathUtils.logGamma(6.0), 1e-6); + } + + @Test + void factorial() + { + assertEquals(1.0, MathUtils.factorial(0), EPS); + assertEquals(1.0, MathUtils.factorial(1), EPS); + assertEquals(2.0, MathUtils.factorial(2), 1e-6); + assertEquals(6.0, MathUtils.factorial(3), 1e-6); + assertEquals(24.0, MathUtils.factorial(4), 1e-6); + assertEquals(120.0, MathUtils.factorial(5), 1e-4); + } + + @Test + void sinc() + { + // sinc(0) = 1 by convention + assertEquals(1.0, MathUtils.sinc(0.0), EPS); + // sinc(pi) = sin(pi)/pi ~ 0 + assertEquals(0.0, MathUtils.sinc(Math.PI), 1e-10); + } + + @Test + void logistic() + { + // logistic with gain=1, offset=0 at value=0 should be 0.5 + double mid = MathUtils.logistic(1.0, 0.0, 0.0); + assertEquals(0.5, mid, 1e-6); + + // large positive -> near 1 + double high = MathUtils.logistic(1.0, 0.0, 100.0); + assertTrue(high > 0.999); + + // large negative -> near 0 + double low = MathUtils.logistic(1.0, 0.0, -100.0); + assertTrue(low < 0.001); + } + + @Test + void square() + { + assertEquals(9.0, MathUtils.square(3.0), EPS); + assertEquals(9.0, MathUtils.square(-3.0), EPS); + assertEquals(0.0, MathUtils.square(0.0), EPS); + } + + @Test + void ramp() + { + // ramp maps value from [low,high] to [0,1] + assertEquals(0.0, MathUtils.ramp(0.0, 0.0, 10.0), EPS); + assertEquals(1.0, MathUtils.ramp(10.0, 0.0, 10.0), EPS); + assertEquals(0.5, MathUtils.ramp(5.0, 0.0, 10.0), EPS); + } + + @Test + void unitmap() + { + // maps value in [0,1] to [low,high] + assertEquals(0.0, MathUtils.unitmap(0.0, 0.0, 10.0), EPS); + assertEquals(10.0, MathUtils.unitmap(1.0, 0.0, 10.0), EPS); + assertEquals(5.0, MathUtils.unitmap(0.5, 0.0, 10.0), EPS); + } + + // ===== number parsing ===== + + @Test + void number() + { + assertTrue(MathUtils.number("3.14")); + assertTrue(MathUtils.number("-1")); + assertTrue(MathUtils.number("0")); + assertFalse(MathUtils.number("abc")); + assertFalse(MathUtils.number("")); + } + + @Test + void parse() + { + assertEquals(3.14, MathUtils.parse("3.14", 0.0), EPS); + assertEquals(0.0, MathUtils.parse("abc", 0.0), EPS); + } + + // ===== array operations ===== + + @Test + void sumDoubleArray() + { + assertEquals(10.0, MathUtils.sum(new double[]{1.0, 2.0, 3.0, 4.0}), EPS); + assertEquals(0.0, MathUtils.sum(new double[]{}), EPS); + } + + @Test + void sumIntArray() + { + assertEquals(10, MathUtils.sum(new int[]{1, 2, 3, 4})); + } + + @Test + void mean() + { + assertEquals(2.5, MathUtils.mean(new double[]{1.0, 2.0, 3.0, 4.0}), EPS); + } + + @Test + void var() + { + // variance of {1,2,3,4}: mean=2.5, var = ((1.5^2+0.5^2+0.5^2+1.5^2)/4) = 1.25 + assertEquals(1.25, MathUtils.var(new double[]{1.0, 2.0, 3.0, 4.0}), 1e-6); + } + + @Test + void dotProduct() + { + assertEquals(32.0, MathUtils.dot(new double[]{1, 2, 3}, new double[]{4, 5, 6}), EPS); + } + + @Test + void norm() + { + assertEquals(5.0, MathUtils.norm(new double[]{3.0, 4.0}), EPS); + assertEquals(0.0, MathUtils.norm(new double[]{0.0, 0.0}), EPS); + } + + @Test + void linspace() + { + double[] result = MathUtils.linspace(0.0, 1.0, 5); + assertEquals(5, result.length); + assertEquals(0.0, result[0], EPS); + assertEquals(1.0, result[4], EPS); + assertEquals(0.25, result[1], EPS); + } + + @Test + void linspaceSinglePointThrows() + { + // linspace requires a < b + assertThrows(IllegalArgumentException.class, () -> MathUtils.linspace(5.0, 5.0, 1)); + } + + @Test + void diff() + { + double[] result = MathUtils.diff(new double[]{1.0, 3.0, 6.0, 10.0}); + assertArrayEquals(new double[]{2.0, 3.0, 4.0}, result, EPS); + } + + @Test + void gradient() + { + // gradient of linear function should be constant + double[] data = new double[]{0.0, 1.0, 2.0, 3.0, 4.0}; + double[] g = MathUtils.gradient(data, 1.0); + assertEquals(5, g.length); + // central differences for interior points should all be 1.0 + for (int i = 1; i < g.length - 1; i++) + { + assertEquals(1.0, g[i], EPS); + } + } + + @Test + void trapezoidArea() + { + // trapezoid with base=4, parallel sides 3 and 5: area = 4*(3+5)/2 = 16 + assertEquals(16.0, MathUtils.trapezoidArea(4.0, 3.0, 5.0), EPS); + } + + @Test + void trapz() + { + // integral of constant function 2.0 from 0 to 3 + double[] x = {0.0, 1.0, 2.0, 3.0}; + double[] y = {2.0, 2.0, 2.0, 2.0}; + assertEquals(6.0, MathUtils.trapz(x, y), EPS); + + // integral of x from 0 to 1 = 0.5 + double[] x2 = {0.0, 0.5, 1.0}; + double[] y2 = {0.0, 0.5, 1.0}; + assertEquals(0.5, MathUtils.trapz(x2, y2), EPS); + } + + @Test + void copyDouble() + { + double[] orig = {1.0, 2.0, 3.0}; + double[] c = MathUtils.copy(orig); + assertArrayEquals(orig, c, EPS); + c[0] = 999.0; + assertEquals(1.0, orig[0], EPS); // verify independence + } + + @Test + void copyInt() + { + int[] orig = {1, 2, 3}; + int[] c = MathUtils.copy(orig); + assertArrayEquals(orig, c); + c[0] = 999; + assertEquals(1, orig[0]); // verify independence + } + + // ===== array arithmetic ===== + + @Test + void plusArray() + { + double[] result = MathUtils.plus(new double[]{1.0, 2.0}, new double[]{3.0, 4.0}); + assertArrayEquals(new double[]{4.0, 6.0}, result, EPS); + } + + @Test + void minusArray() + { + double[] result = MathUtils.minus(new double[]{5.0, 8.0}, new double[]{1.0, 3.0}); + assertArrayEquals(new double[]{4.0, 5.0}, result, EPS); + } + + @Test + void timesArray() + { + double[] result = MathUtils.times(new double[]{2.0, 3.0}, new double[]{4.0, 5.0}); + assertArrayEquals(new double[]{8.0, 15.0}, result, EPS); + } + + @Test + void timesScalar() + { + double[] result = MathUtils.times(new double[]{1.0, 2.0, 3.0}, 2.0); + assertArrayEquals(new double[]{2.0, 4.0, 6.0}, result, EPS); + } + + @Test + void plusScalar() + { + double[] result = MathUtils.plus(new double[]{1.0, 2.0}, 10.0); + assertArrayEquals(new double[]{11.0, 12.0}, result, EPS); + } + + @Test + void sqrtArray() + { + double[] result = MathUtils.sqrt(new double[]{4.0, 9.0, 16.0}); + assertArrayEquals(new double[]{2.0, 3.0, 4.0}, result, EPS); + } + + @Test + void powArray() + { + double[] result = MathUtils.pow(new double[]{2.0, 3.0}, 2.0); + assertArrayEquals(new double[]{4.0, 9.0}, result, EPS); + } + + // ===== min/max ===== + + @Test + void minArray() + { + assertEquals(1.0, MathUtils.min(new double[]{3.0, 1.0, 5.0, 2.0}), EPS); + } + + @Test + void maxArray() + { + assertEquals(5.0, MathUtils.max(new double[]{3.0, 1.0, 5.0, 2.0}), EPS); + } + + @Test + void minidx() + { + assertEquals(1, MathUtils.minidx(new double[]{3.0, 1.0, 5.0, 2.0})); + } + + @Test + void maxidx() + { + assertEquals(2, MathUtils.maxidx(new double[]{3.0, 1.0, 5.0, 2.0})); + } + + @Test + void minThreeArgs() + { + assertEquals(1, MathUtils.min(3, 1, 2)); + assertEquals(1.0, MathUtils.min(3.0, 1.0, 2.0), EPS); + } + + @Test + void maxThreeArgs() + { + assertEquals(3, MathUtils.max(3, 1, 2)); + assertEquals(3.0, MathUtils.max(3.0, 1.0, 2.0), EPS); + } + + // ===== boolean array operations ===== + + @Test + void threshold() + { + boolean[] result = MathUtils.threshold(new double[]{1.0, 5.0, 3.0, 7.0}, 4.0); + assertFalse(result[0]); + assertTrue(result[1]); + assertFalse(result[2]); + assertTrue(result[3]); + } + + @Test + void countTrue() + { + assertEquals(2, MathUtils.counttrue(new boolean[]{true, false, true, false})); + assertEquals(0, MathUtils.counttrue(new boolean[]{false, false})); + } + + @Test + void countFalse() + { + assertEquals(2, MathUtils.countfalse(new boolean[]{true, false, true, false})); + } + + @Test + void flip() + { + boolean[] result = MathUtils.flip(new boolean[]{true, false, true}); + assertFalse(result[0]); + assertTrue(result[1]); + assertFalse(result[2]); + } + + @Test + void and() + { + boolean[] result = MathUtils.and( + new boolean[]{true, true, false, false}, + new boolean[]{true, false, true, false} + ); + assertTrue(result[0]); + assertFalse(result[1]); + assertFalse(result[2]); + assertFalse(result[3]); + } + + @Test + void or() + { + boolean[] result = MathUtils.or( + new boolean[]{true, true, false, false}, + new boolean[]{true, false, true, false} + ); + assertTrue(result[0]); + assertTrue(result[1]); + assertTrue(result[2]); + assertFalse(result[3]); + } + + // ===== int array operations ===== + + @Test + void plusIntArray() + { + int[] result = MathUtils.plus(new int[]{1, 2, 3}, 10); + assertArrayEquals(new int[]{11, 12, 13}, result); + } + + // ===== permutation ===== + + @Test + void permutationDouble() + { + // permutation returns indices that sort the array ascending + int[] perm = MathUtils.permutation(new double[]{30.0, 10.0, 20.0}); + assertEquals(1, perm[0]); // 10.0 is smallest + assertEquals(2, perm[1]); // 20.0 is middle + assertEquals(0, perm[2]); // 30.0 is largest + } + + @Test + void permutationInt() + { + int[] perm = MathUtils.permutation(new int[]{30, 10, 20}); + assertEquals(1, perm[0]); + assertEquals(2, perm[1]); + assertEquals(0, perm[2]); + } + + @Test + void inversePerm() + { + int[] perm = {2, 0, 1}; + int[] inv = MathUtils.inverse(perm); + // inv[perm[i]] == i for all i + for (int i = 0; i < perm.length; i++) + { + assertEquals(i, inv[perm[i]]); + } + } + + // ===== distance ===== + + @Test + void distance() + { + assertEquals(5.0, MathUtils.distance(new double[]{0, 0}, new double[]{3, 4}), EPS); + assertEquals(0.0, MathUtils.distance(new double[]{1, 2}, new double[]{1, 2}), EPS); + } + + // ===== constant/polyWeights ===== + + @Test + void constantWeights() + { + double[] w = MathUtils.constantWeights(3); + assertEquals(3, w.length); + for (double v : w) + { + assertEquals(1.0 / 3.0, v, EPS); + } + } + + // ===== 2D array operations ===== + + @Test + void sum2D() + { + double[][] m = {{1.0, 2.0}, {3.0, 4.0}}; + assertEquals(10.0, MathUtils.sum(m), EPS); + } + + @Test + void copy2D() + { + double[][] orig = {{1.0, 2.0}, {3.0, 4.0}}; + double[][] c = MathUtils.copy(orig); + assertEquals(orig[0][0], c[0][0], EPS); + c[0][0] = 999.0; + assertEquals(1.0, orig[0][0], EPS); + } + + @Test + void frobeniusNorm() + { + double[][] data = {{1.0, 2.0}, {3.0, 4.0}}; + // frobenius = sqrt(1+4+9+16) = sqrt(30) + assertEquals(Math.sqrt(30.0), MathUtils.frobeniusNorm(data), EPS); + } + + @Test + void transpose() + { + double[][] m = {{1.0, 2.0, 3.0}, {4.0, 5.0, 6.0}}; + double[][] t = MathUtils.transpose(m); + assertEquals(3, t.length); + assertEquals(2, t[0].length); + assertEquals(1.0, t[0][0], EPS); + assertEquals(4.0, t[0][1], EPS); + assertEquals(2.0, t[1][0], EPS); + assertEquals(5.0, t[1][1], EPS); + } +}