diff --git a/.github/workflows/docs-verify.yml b/.github/workflows/docs-verify.yml new file mode 100644 index 00000000..18c603e2 --- /dev/null +++ b/.github/workflows/docs-verify.yml @@ -0,0 +1,32 @@ +name: Documentation + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + paths: + - "docs/**" + +jobs: + # Just check that the build works and doesn't throw any errors + # The actual build and deployment is done on the main branch + # with another GitHub Actions workflow. + build: + name: Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + cache-version: 0 # Increment this number if you need to re-download cached gems + working-directory: '${{ github.workspace }}/docs' + + - name: Build with Jekyll + run: bundle exec jekyll build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..ee97c592 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,69 @@ +# See the template here: https://github.com/just-the-docs/just-the-docs-template + +name: Documentation + +on: + push: + branches: + - "main" + paths: + - "docs/**" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + cache-version: 0 # Increment this number if you need to re-download cached gems + working-directory: '${{ github.workspace }}/docs' + + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Build with Jekyll + # Outputs to the './_site' directory by default + run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" + env: + JEKYLL_ENV: production + + # Automatically creates an github-pages artifact used by the deployment job + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "docs/_site/" + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d9c0044e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release package + +on: + release: + types: [published] + +jobs: + releasepypi: + # see https://docs.pypi.org/trusted-publishers/using-a-publisher/ + # and https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives + name: Release to PyPI + runs-on: "ubuntu-latest" + environment: release + permissions: + id-token: write + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + cache: 'pipenv' + + - name: Run tests (one last time before release) + run: | + pip install pipenv + pipenv install --dev + pipenv run pip3 install --editable . + pipenv run pytest tests/ + + - name: Install build tooling + run: python3 -m pip install --upgrade build + + - name: Build distributions + run: python3 -m build + + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d48587c..d6da7942 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,9 +29,12 @@ jobs: - name: Install dependencies (including dev dependencies) run: pipenv install --dev + - name: Install ResultWizard itself (as editable) + run: pipenv run pip3 install --editable . + - name: Run Pytest - run: pipenv run pytest + run: pipenv run pytest tests/ -# [1] https://github.com/orgs/community/discussions/26366 \ No newline at end of file +# [1] https://github.com/orgs/community/discussions/26366 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2f0ed452..bd636e55 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,13 +19,19 @@ "pythonTestExplorer.testFramework": "pytest", "testExplorer.codeLens": true, "testExplorer.errorDecorationHover": true, + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, ////////////////////////////////////// // Files ////////////////////////////////////// "files.exclude": { "**/__pycache__/": true, "**/*.egg-info/": true, - ".pytest_cache/": true + "**/.pytest_cache/": true, + "**/.jekyll-cache/": true }, ////////////////////////////////////// // Editor @@ -56,6 +62,8 @@ // Spell Checker ////////////////////////////////////// "cSpell.words": [ + "Commandifier", + "getcontext", "github", "ifthen", "ifthenelse", @@ -64,20 +72,22 @@ "newcommand", "normalsize", "pipenv", + "prec", "pydantic", "pylint", "pytest", + "resultwizard", + "scriptsize", + "scriptstyle", + "setcontext", "sigfigs", "siunitx", "Stringifier", "textbf", "texttt", + "TLDR", "uncert", + "uncerts", "usepackage" - ], - "python.testing.pytestArgs": [ - "." - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 142531cf..4f7e8678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -# Changelog of ValueWizard +# Changelog -TODO +👀 Nothing here yet diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9a220b05..01e04685 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -9,7 +9,7 @@ Getting ready: - [ ] Recommended VSCode extensions installed (especially the formatter. It should automatically format on every save!) - [ ] on branch `value-wizard` with latest commit pulled - [ ] Work through the `Setup` section below (especially to install the necessary dependencies) -- [ ] Read the [`README.md`](https://github.com/paul019/ValueWizard/tree/value-wizard/src#code-structure) in the `src` folder (to get to know the code structure) & see our [feature list](https://github.com/paul019/ValueWizard/issues/16) +- [ ] Read the [`README.md`](https://github.com/resultwizard/ResultWizard/tree/main/src#code-structure) in the `src` folder (to get to know the code structure) & see our [feature list](https://github.com/resultwizard/ResultWizard/issues/16) Verify that everything worked: - [ ] try to run the tests, see the instructions in [`tests/playground.py`](./tests/playground.py) @@ -66,3 +66,15 @@ Also try adding `import pytest` as first line of a test file. Does it give you a Note that tests are also run on every commit via a GitHub action. In order to learn how to write the tests with pytest, start with the [`Get Started` guide](https://docs.pytest.org/en/8.0.x/getting-started.html#create-your-first-test). Probably also relevant: ["How to use fixtures"](https://docs.pytest.org/en/8.0.x/how-to/fixtures.html). There are lots of [How-to guides](https://docs.pytest.org/en/8.0.x/how-to/index.html) available. + + +## Release to PyPI + +To release a new version to [PyPI](https://pypi.org/project/resultwizard/), do the following: + +- Create a PR that is going to get merged into `main`. Name it "Continuous Release ". +- Make sure all tests pass and review the PR. Merge it into `main` via a *Merge commit* +
(from `dev` to `main` always via *Merge commit*, not *Rebase* or *Squash and merge*). +- On `main`, create a new release on GitHub. In this process (via the GitHub UI), create a new tag named "v", e.g. "v1.0.0-alpha.42". +- The tag creation will trigger a GitHub action that builds and uploads the package to PyPI. As this action uses a special "release" environment, code owners have to approve this step. +- Make sure the new version is available on PyPI [here](https://pypi.org/project/resultwizard/). diff --git a/Pipfile b/Pipfile index f1651b4f..70624017 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,6 @@ verify_ssl = true name = "pypi" [packages] -plum-dispatch = "*" [dev-packages] pylint = "~=3.0" diff --git a/Pipfile.lock b/Pipfile.lock index ea9b0240..94768b86 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e32904fba65ee53f95fbe90b1633c0bd9f71823a326e0e4ed6942d78b8270d34" + "sha256": "a5f83ecca60c4365a35a8eb10b1051286f4099f88ff14dab578be8367888ba0e" }, "pipfile-spec": 6, "requires": { @@ -15,57 +15,7 @@ } ] }, - "default": { - "beartype": { - "hashes": [ - "sha256:c22b21e1f785cfcf5c4d3d13070f532b6243a3ad67e68d2298ff08d539847dce", - "sha256:e911e1ae7de4bccd15745f7643609d8732f64de5c2fb844e89cbbed1c5a8d495" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==0.17.2" - }, - "markdown-it-py": { - "hashes": [ - "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", - "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" - ], - "markers": "python_version >= '3.8'", - "version": "==3.0.0" - }, - "mdurl": { - "hashes": [ - "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", - "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" - ], - "markers": "python_version >= '3.7'", - "version": "==0.1.2" - }, - "plum-dispatch": { - "hashes": [ - "sha256:96f519d416accf9a009117682f689114eb23e867bb6f977eed74ef85ef7fef9d", - "sha256:f49f00dfdf7ab0f16c9b85cc27cc5241ffb59aee02218bac671ec7c1ac65e139" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.3.2" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "rich": { - "hashes": [ - "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", - "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.1" - } - }, + "default": {}, "develop": { "astroid": { "hashes": [ diff --git a/README.md b/README.md index b999a173..a594386d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@
- + + +

ResultWizard

-

Intelligent interface between Python-computed values and your LaTeX work

+

Intelligent interface between Python-computed values and your LaTeX work

+Annoyed of having to copy around values from Python code to your LaTeX work? Think of `ResultWizard` as an interface between the two. Export any variables from Python including possible uncertainties and their units and directly reference them in your LaTeX document. -## `ResultWizard` is ... - -- a -- b - - -## Usage +> **Warning ⚠** +> ResultWizard is still in its *alpha* stage. We're happy to receive your feedback, e.g. report any bugs. But note that the API might still change before we hit the first stable release 1.0.0. +> **📄** +> **For installation/usage/API, refer to our [documentation](https://resultwizard.github.io/ResultWizard/).** diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..15c58d03 --- /dev/null +++ b/TODO.md @@ -0,0 +1,33 @@ +## Documentation + +**Things we should include in the documentation:** + +- an overview of what the goal of the project is with a small illustration where we sketch python code, the `results.tex` file and the LaTeX document and explain how they are connected +- most common ways to use the library, but then also a comprehensive list of all possible ways to call `res` (preferable on a separate page of the documentation). How to guides for most common cases +- ways to use the variable in LaTeX code, e.g. list all keys, tell users they should enter \resMyVariable[test] and then see the error message to find out what the possible keys are +- passing in as string means exact value +- how to specify the name of a variable. Numbers only allowed from 0 up to 1000. Special characters are stripped. Explain camel case etc. +-> Explain that this has the great potential for loops: users can specify variables in a loop and use format strings, e.g. `wiz.res(f"my_variable_{i}", ...)` +- how to pass in uncertainties. How to pass in one? What about systematic and statistical ones. What if I want to add my own name for the uncertainty? How can I control that output. +- a list of all possible keys for `config_init` including their default values, e.g. `identifier`. In-depth explanation especially for sigfigs and decimal places and how they differ from respective fallback options +- a hint that the output is completely customizable and that the user can change it with the `\sisetup{...}`, e.g. `\cdot` vs. `\times` for exponent, `separate-uncertainty=true` (!) +- how to use the unit string. explain that strings from `siunitx` can be passed in, e.g. `\cm \per \N` etc. Explain how python raw strings can help, e.g. `r"\cm \per \N"` instead of having to do `\\cm` etc. all the time. However, `r'\\tesla'` will fail as the double backslash is treated a raw string and not as an escape character. Use `r'\tesla'` instead. +- possible ways to print a result. Recommended: activate `print_auto`. Other way: call `print()` on result object. Users can also call `resVariable.to_latex_str()` to retrieve the LaTeX representation. This can be useful to plot the result in a matplotlib figure, e.g. the fit parameter of a curve fit. +- Suggest some good initial configuration for Jupyter notebook, e.g. `print_auto=True` and `ignore_result_overwrite=True`. +- Naming: we call it "uncertainty". Give a hint that others might also call it "error" interchangeably. +- Jupyter Notebook tip to avoid + +``` + +``` +as output. Instead append a `;` to the `wiz.res(...)` call and the output will be suppressed. + +- Use fuzzy search in IntelliSense to search for result names. + + + +## Other + +- Setup issue template and contribution guide. Clean up `DEVELOPMENT.md`. +- Long-term: Ask real users what they really need in the scientific day-to-day life, see [here](https://github.com/resultwizard/ResultWizard/issues/9). +- If user enters an uncertainty of `0.0`, don't just issue warning "Uncertainty must be positive", but also give a hint that the user might want to use a different caller syntax for `res` which does not even have the uncertainty as argument. diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..93b45e81 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,12 @@ +# These are directly copied from Jekyll's first-party docs on `.gitignore` files: +# https://jekyllrb.com/tutorials/using-jekyll-with-bundler/#commit-to-source-control + +# Ignore the default location of the built site, and caches and metadata generated by Jekyll +_site/ +.sass-cache/ +.jekyll-cache/ +.jekyll-metadata + +# Ignore folders generated by Bundler +.bundle/ +vendor/ diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 00000000..f75bc878 --- /dev/null +++ b/docs/404.html @@ -0,0 +1,26 @@ +--- +permalink: /404.html +layout: default +--- + + + +
+

404

+ +

Page not found 😥

+

The requested page could not be found.

+
diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 00000000..25440b6f --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +gem "jekyll", "~> 4.3.3" # installed by `gem jekyll` +gem "just-the-docs", "0.8.1" # pinned to the current release diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 00000000..017aea1b --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,82 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) + colorator (1.1.0) + concurrent-ruby (1.2.3) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + eventmachine (1.2.7) + ffi (1.16.3) + forwardable-extended (2.6.0) + google-protobuf (4.26.1-x86_64-linux) + rake (>= 13) + http_parser.rb (0.8.0) + i18n (1.14.4) + concurrent-ruby (~> 1.0) + jekyll (4.3.3) + addressable (~> 2.4) + colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 1.0) + jekyll-sass-converter (>= 2.0, < 4.0) + jekyll-watch (~> 2.0) + kramdown (~> 2.3, >= 2.3.1) + kramdown-parser-gfm (~> 1.0) + liquid (~> 4.0) + mercenary (>= 0.3.6, < 0.5) + pathutil (~> 0.9) + rouge (>= 3.0, < 5.0) + safe_yaml (~> 1.0) + terminal-table (>= 1.8, < 4.0) + webrick (~> 1.7) + jekyll-include-cache (0.2.1) + jekyll (>= 3.7, < 5.0) + jekyll-sass-converter (3.0.0) + sass-embedded (~> 1.54) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + just-the-docs (0.8.1) + jekyll (>= 3.8.5) + jekyll-include-cache + jekyll-seo-tag (>= 2.0) + rake (>= 12.3.1) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.4) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + mercenary (0.4.0) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (5.0.5) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + rexml (3.2.6) + rouge (4.2.1) + safe_yaml (1.0.5) + sass-embedded (1.74.1-x86_64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.5.0) + webrick (1.8.1) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + jekyll (~> 4.3.3) + just-the-docs (= 0.8.1) + +BUNDLED WITH + 2.4.22 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..eccaa7d3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# ResultWizard documentation + +🧾 Find the documentation [here](https://resultwizard.github.io/ResultWizard/). + +To build and preview the docs locally, see [this](https://github.com/just-the-docs/just-the-docs-template?tab=readme-ov-file#building-and-previewing-your-site-locally). In summary, have [Bundler](https://bundler.io/) installed, then run: + +```bash +cd docs/ +bundle install +bundle exec jekyll serve +``` + +Preview the docs at [localhost:4000](http://localhost:4000). Files are stored in the `_site` directory locally. diff --git a/docs/_api/config.md b/docs/_api/config.md new file mode 100644 index 00000000..cdadfbb8 --- /dev/null +++ b/docs/_api/config.md @@ -0,0 +1,54 @@ +--- +layout: default +title: wiz.config_init() & wiz.config() +nav_order: 1 +--- + +# `wiz.config_init` & `wiz.config()` +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ + +## Purpose + +The methods `wiz.config_init()` and `wiz.config()` allow you to configure `ResultWizard` to your needs. Note that this mainly affects the rounding mechanism as well as convenience features. How the results are formatted in the LaTeX document is mainly controlled by the `siunitx` package and how you set it up in your LaTeX preamble. If this is what you want to configure, then you should take a look [here]({{site.baseurl}}/tips/siunitx). + +## Usage + +With `config_init()` you set the initial configuration for `ResultWizard`. With later calls to `config()`, you can update individual settings without having to reconfigure every parameter. + +{: .warning } +Some options are only available in `config_init()` and cannot be changed later with `config()`. +
TODO: Do we really want that? + +Here is the list of available options: + +{: .warning } +TODO: sort options alphabetically? Make clearer what the difference between `sigfigs` and `sigfigs_fallback` is. Maybe even rename/unify these options? Same for `decimal_places` and `decimal_places_fallback`. We also need better explanations for `min_exponent_...` and `max_exponent_...`. + +| Option | Default | Available in
`config_init()` | Available in
`config()` | Description | +|:---|:---:|:---:|:---:|:---| +| `sigfigs` (int) | `-1` | ✔ | ✔ | The number of significant figures to round to.
TODO: explain what a sigfig is. | +| `decimal_places` (int) | `-1` | ✔ | ✔ | The number of decimal places to round to. | +| `sigfigs_fallback` (int) | `2` | ✔ | ✔ | The number of significant figures to use as a fallback if other rounding rules don't apply. | +| `decimal_places_fallback` (int) | `-1` | ✔ | ✔ | The number of decimal places to use as a fallback if other rounding rules don't apply. | +| `identifier` (str) | `"result"` | ✔ | | The identifier that will be used in the LaTeX document to reference the result. | +| `print_auto` (bool) | `False` | ✔ | ✔ | If `True`, every call to `wiz.res()` will automatically print the result to the console, such that you don't have to use `.print()` on every single result. | +| `export_auto_to` (str) | `""` | ✔ | | If set to a path, every call to `wiz.res()` will automatically export the result to the specified file. This is especially useful for Jupyter notebooks where every execution of a cell that contains a call to `wiz.res()` will automatically export to the file. | +| `siunitx_fallback` (bool) | `False` | ✔ | | If `True`, `ResultWizard` will use a fallback for the `siunitx` package if you have an old version installed. See [here]({{site.baseurl}}/trouble#package-siunitx-invalid-number) for more information. We don't recommend to use this option and instead upgrade your `siunitx` version to exploit the full power of `ResultWizard`. | +`precision` (int) | `100` | ✔ | | The precision `ResultWizard` uses internally to handle the floating point numbers. You may have to increase this number if you encounter the error "Your precision is set too low". | +| `ignore_result_overwrite` (bool) | `False` | ✔ | | If `True`, `ResultWizard` will not raise a warning if you overwrite a result with the same identifier. This is especially useful for Jupyter notebooks where cells are oftentimes run multiple times. | +| `min_exponent_for_`
`non_scientific_notation` (int) | `-2` | ✔ | | The minimum exponent for which `ResultWizard` will use non-scientific notation. If the exponent is smaller than this value, scientific notation will be used. TODO: explain better. | +| `max_exponent_for_`
`non_scientific_notation` (int) | `3` | ✔ | | The maximum exponent for which `ResultWizard` will use non-scientific notation. If the exponent is larger than this value, scientific notation will be used. TODO: explain better. | + +If you're using a Jupyter Notebook, you might find [this configuration]({{site.baseurl}}/tips/jupyter) useful. diff --git a/docs/_api/export.md b/docs/_api/export.md new file mode 100644 index 00000000..a476f087 --- /dev/null +++ b/docs/_api/export.md @@ -0,0 +1,39 @@ +--- +layout: default +title: wiz.export() +nav_order: 3 +--- + +# `wiz.export()` +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ + +## Purpose + +Call `wiz.export()` after you have defined your results with `wiz.res()`. `wiz.export()` will generate a LaTeX file containing all your results. This file can be included in your LaTeX document with `\input{./path/to/results.tex}` in the LaTeX preamble (see [here]({{site.baseurl}}/quickstart#2-include-results-in-latex)). + +## Usage + +```py +wiz.export(filepath: str) +``` + +- `filepath` (str): The (relative or absolute) path to the LaTeX file to be generated, e.g. `./results.tex`. + + +## Tips + +- The `filepath` should end with `.tex` to be recognized as a LaTeX file by your IDE / LaTeX editor. +- For a convenient setup, have Python code reside next to your LaTeX document. This way, you can easily reference the generated LaTeX file. For example, you could have two folders `latex/` & `code/` in your project. Then export the results to `../latex/results.tex` from your python code residing in the `code` folder. In LaTeX, you can then include the file with `\input{./results.tex}`. +- Especially for Jupyter Notebooks, we recommend to use the [`export_auto_to` config option]({{site.baseurl}}/api/config#export_auto_to). This way, you can automatically export the results to a file after each call to `wiz.res()`. See [this page]({{site.baseurl}}/tips/jupyter) for a suitable configuration of `ResultWizard` in Jupyter Notebooks. diff --git a/docs/_api/res.md b/docs/_api/res.md new file mode 100644 index 00000000..0f689375 --- /dev/null +++ b/docs/_api/res.md @@ -0,0 +1,123 @@ +--- +layout: default +title: wiz.res() +nav_order: 2 +--- + +# `wiz.res()` +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ +{: .warning} +The API for `wiz.res()` is not yet finalized as of `v1.0.0a2` and might change before the stable release `1.0.0`. This is due to some issues we are currently experiencing with the multiple dispatch mechanism in [`plum`]. + + +## Purpose + +`wiz.res()` is at the heart of `ResultWizard`. With this method, you define your results, i.e. numerical values with uncertaintie(s) (optional) and a unit (optional). See the [basic usage]({{site.baseurl}}/quickstart#-basic-usage) for a first example. + + +When we talk about a **"measurement result"**, we usually refer to these components: + +- _Value_: The numerical value of your measurement, i.e. the value you have measured and that you are interested in. +- _Uncertainties_: They denote the precision of your measurement since you can never measure a value exactly in the real world. Another term commonly used for this is "error", but we will use "uncertainty" throughout. +- _Unit_: The SI unit of your measurement, e.g. meters, seconds, kilograms etc. + + +## Usage + + +### Define a result + +`wiz.res()` is overloaded[^1], i.e. you can call it with different argument types and even different number of arguments. This allows you to define your results in a way that suits you best, e.g. sometimes you might only have the value without any uncertainties, or you don't need a unit etc. + +In the following, we use these abbreviations. Refer to the [python docs](https://docs.python.org/3/library/decimal.html) if you're unsure about how `Decimal` works. We recommend using `Decimal` for all numerical values to avoid floating point errors. Also see the [precision page](TODO). +```py +numlike := float | int | str | Decimal +numlike_without_int := float | str | Decimal +uncertainties := Tuple[numlike, str] | List[numlike | Tuple[numlike, str]] +``` + +These are the possible ways to call `wiz.res()`. Note you can use IntelliSense (`Ctrl + Space`) in your IDE to see all possible signatures and get error messages if you use the arguments incorrectly. +```py +wiz.res(name: str, value: numlike) +wiz.res(name: str, value: numlike, unit: str = "") +wiz.res(name: str, value: numlike, uncert: numlike | uncertainties) +wiz.res(name: str, value: numlike, sys: float | Decimal, stat: float | Decimal, unit: str = "") +wiz.res(name: str, value: numlike, uncert: numlike_without_int | uncertainties | None, unit: str = "") +``` + +{: .warning } +Some signatures of `wiz.res()` don't allow for an `int` to be passed in. This is currently due to a technical limitation that we are trying to work around before the stable release. + +Note that `uncert` stands for "uncertainties" and can be a single value (for symmetric uncertainties) or a list (for asymmetric uncertainties). When you specify a tuple, the first element is the numerical value of the uncertainty, the second element is a string that describes the type of uncertainty, e.g. "systematic", "statistical" etc. +```py +wiz.res("i did it my way", 42.0, [0.22, (0.25, "systematic"), (0.314, "yet another one")]) + +# These two lines are equivalent (the last line is just a convenient shorthand) +# Note however with the last line, you cannot pass in "0.1" or "0.2" as strings. +wiz.res("atom diameter", 42.0, [(0.1, "sys"), (0.2, "stat")]) +wiz.res("atom diameter", 42.0, 0.1, 0.2) +``` + + +### Override the rounding mechanism + +Sometimes, you don't want a result to be rounded at all. You can tell `ResultWizard` to not round a numerical value by passing this value as string instead: +```py +calculated_uncert = 0.063 +wiz.res("abc", "1.2345", str(calculated_uncert)).print() +# will print: abc = 1.2345 ± 0.063 +``` + +You might also use the following keyword arguments with any signature of `wiz.res()`. They will override whatever you have configured via [`config_init()` or `config()`]({{site.baseurl}}/api/config), but just for the specific result. +```py +wiz.res(name, ..., sigfigs: int = None, decimal_places: int = None) +``` + + +### Return type + +`wiz.res()` returns a `PrintableResult`. On this object, you can call: + +```py +my_res = wiz.res("abc", 1.2345, 0.063) +my_res.print() # will print: abc = 1.23 ± 0.06 +my_latex_str = my_res.to_latex_str() +print(my_latex_str) # will print: \num{1.23 \pm 0.06} +``` + +- `print()` will print the result to the console. If you find yourself using this a lot, consider setting the [`print_auto` config option]({{site.baseurl}}/api/config#print_auto) to `True`, which will automatically print the result after each call to `wiz.res()`. +- `to_latex_str()` converts the result to a LaTeX string. This might be useful if you want to show the result as label in a `matplotlib` plot. For this to work, you have to tell `matplotlib` that you're using `siunitx` by defining the preamble in your Python script: +```py +import matplotlib.pyplot as plt +plt.rc('text.latex', preamble=r""" + \usepackage{siunitx} + \sisetup{locale=US, group-separator={,}, group-digits=integer, + per-mode=symbol, separate-uncertainty=true}""") +``` + + + +## Tips + +You might need a variable in your LaTeX document multiple times: in one place _with_ a unit and in another one _without_ a unit (or uncertainty etc.). Don't define the result twice in this case. +Instead, call `wiz.res()` once and pass in everything you know about your result, e.g. value, unit, uncertainties. Then use `$$\resultMyVariableName[withoutUnit]$$` or `$$\resultMyVariableName[unit]$$` etc. in the LaTeX document to only use a specific part of the result. See the [quickstart]({{site.baseurl}}/quickstart#latex-subset-syntax) for more information. + + +--- + +[^1]: For the technically interested: we use [`plum`] to achieve this "multiple dispatch" in Python. Natively, Python does not allow for method overloading, a concept you might know from other programming languages like Java. + + +[`plum`]: https://github.com/beartype/plum \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..c1be61f1 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,41 @@ +title: ResultWizard +description: Documentation for the ResultWizard Python library. +theme: just-the-docs +include: ["pages"] +logo: "https://github.com/resultwizard/ResultWizard/assets/37160523/e3ce32b9-2e41-4ddc-88e3-c1adadd305e9" + +url: https://resultwizard.github.io/ResultWizard/ + +aux_links: + GitHub: https://github.com/resultwizard/ResultWizard + Report a bug: https://github.com/resultwizard/ResultWizard/issues + PyPI: https://pypi.org/project/resultwizard/ + +collections: + api: + permalink: "/:collection/:path/" + output: true + tips: + permalink: "/:collection/:path/" + output: true +just_the_docs: + collections: + api: + name: API Reference + nav_fold: false + tips: + name: Tips/Guides + nav_fold: false + +enable_copy_code_button: true + +color_scheme: resultwizard-colors +callouts: + warning: + title: Warning + color: resultwizard-warning-purple + opacity: 0.1 + tldr: + title: TL;DR + color: resultwizard-tldr + opacity: 0.15 diff --git a/docs/_includes/nav_footer_custom.html b/docs/_includes/nav_footer_custom.html new file mode 100644 index 00000000..3f94c423 --- /dev/null +++ b/docs/_includes/nav_footer_custom.html @@ -0,0 +1,4 @@ + diff --git a/docs/_includes/title.html b/docs/_includes/title.html new file mode 100644 index 00000000..6b6eb0a9 --- /dev/null +++ b/docs/_includes/title.html @@ -0,0 +1,4 @@ +{% if site.logo %} + +{% endif %} +{{ site.title }} \ No newline at end of file diff --git a/docs/_sass/color_schemes/resultwizard-colors.scss b/docs/_sass/color_schemes/resultwizard-colors.scss new file mode 100644 index 00000000..783d1c34 --- /dev/null +++ b/docs/_sass/color_schemes/resultwizard-colors.scss @@ -0,0 +1,6 @@ +$resultwizard-blue: #1C6CB3; +$resultwizard-purple-light: #A03F70; +$resultwizard-purple-dark: #773377; + +$link-color: $resultwizard-purple-dark; +$btn-primary-color: $resultwizard-purple-dark; diff --git a/docs/_sass/custom/setup.scss b/docs/_sass/custom/setup.scss new file mode 100644 index 00000000..a1fbd3bd --- /dev/null +++ b/docs/_sass/custom/setup.scss @@ -0,0 +1,4 @@ +$resultwizard-warning-purple-000: #A03F70; +$resultwizard-warning-purple-300: #773377; +$resultwizard-tldr-000: #4699CD; +$resultwizard-tldr-300: #1C6CB3; diff --git a/docs/_tips/jupyter.md b/docs/_tips/jupyter.md new file mode 100644 index 00000000..110d8f18 --- /dev/null +++ b/docs/_tips/jupyter.md @@ -0,0 +1,54 @@ +--- +layout: default +title: Jupyter Notebook +nav_order: 1 +--- + +# Jupyter Notebook +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ +{: .warning } +Note that using a Jupyter Notebook **in a browser** won't make much sense in conjunction with `ResultWizard` as you won't be able to directly include the results in your LaTeX document. However, you can still use `ResultWizard` to export the `results.tex` file and copy the contents manually into your LaTeX document. But this is not the originally intended workflow and might be tedious +
Note that VSCode offers great support for Jupyter Notebook natively, see [this guide](https://code.visualstudio.com/docs/datascience/jupyter-notebooks). + +## Useful configuration + +In the context of a [*Python Jupyter Notebook*](https://jupyter.org/), you might find this `ResultWizard` configuration useful: +```py +wiz.config_init(print_auto=True, export_auto_to="./results.tex", ignore_result_overwrite=True) +``` +- With the `print_auto` option set to `True`, you will see the results printed to the console automatically without having to call `.print()` on them. +- The `export_auto_to` option will automatically export the results to a file each time you call `.res()`. That is, you don't have to scroll down to the end of your notebook to call `wiz.export()`. Instead, just execute the cell where you call `.res()` and the results will be exported to the file you specified automatically. +- With the `ignore_result_overwrite` option you won't see a warning if you overwrite a result with the same name. This is useful in Jupyter notebooks since cells are often executed multiple times. + + +## Cell execution order & cache + +Watch out if you use [`wiz.config()`]({{site.baseurl}}/api/config) in a Jupyter Notebook. The order of the cell execution is what matters, not where they appear in the document. E.g. you might call `wiz.config()` somewhere at the end of your notebook. Then go back to the top and execute a cell with `wiz.res()`. The configuration will be applied to this cell as well. This is an inherent limitation/feature of Jupyter Notebooks, just be aware of it. + +It might be useful to reset the kernel and clear all outputs from time to time. This way, you can also check if your notebook still runs as expected from top to bottom and exports the results correctly. It can also help get rid of any clutter in `results.tex`, e.g. if you have exported a variable that you deleted later on in the code. This variable will still be in `results.tex` as deleting the `wiz.res()` line in the code doesn't remove the variable from the cache. + + +## Omit output from last line + +In interactive python environments like Jupyter Notebooks, the last line of a cell is automatically printed to the console, so you might see something like the following under a cell: + +``` + +``` + +If you don't want this, you can add a semicolon `;` at the end of the last line in the cell (also see [this StackOverflow answer](https://stackoverflow.com/a/45519070/)). This will suppress the output. For example, write this: +```py +wiz.res("jupyter notebook output", 5.0, 0.1, unit="\m\per\s^2"); # <-- note the semicolon here +``` \ No newline at end of file diff --git a/docs/_tips/siunitx.md b/docs/_tips/siunitx.md new file mode 100644 index 00000000..7f25663c --- /dev/null +++ b/docs/_tips/siunitx.md @@ -0,0 +1,58 @@ +--- +layout: default +title: Siunitx Configuration +nav_order: 2 +--- + +# `siunitx` Configuration +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ + +## Purpose + +The [`siunitx`] package offers a wide range of options to configure the formatting of numbers and units in LaTeX. In the exported `results.tex` file, we make use of `siunitx` syntax, e.g. we might transform a `wiz.res()` call into something like `\qty{1.23 \pm 0.05}{\m\per\s^2}` that you also could have written manually. This means, you have full control over how the numbers and units are displayed in your LaTeX document by configuring `siunitx` itself. + +If you want to configure `ResultWizard` itself instead, see the [`config_init()` & `config()`]({{site.baseurl}}/api/config) methods. + + +## Important configuration options + +All options are specified as `key=value` pairs in `\sisetup{}` inside your LaTeX preamble (before `\begin{document}`), e.g.: +```latex +\usepackage{siunitx} +\sisetup{ + locale=US, + group-separator={,}, + group-digits=integer, + per-mode=symbol, + uncertainty-mode=separate, +} +``` + +Here, we present just a small (admittedly random) subset of the options of [`siunitx`]. See the **[`siunitx` documentation](https://texdoc.org/serve/siunitx/0).** for more details and all available options. + +[Siunitx Documentation](https://texdoc.org/serve/siunitx/0){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } + +- `locale=UK|US|DE|PL|FR|SI|ZA`. This option sets the locale for the output of numbers and units, e.g. in English speaking countries, the decimal separator is a dot `1.234`, while in Germany it's a comma `1,234`. +- `group-separator={,}`. This option sets the group separator, e.g. `1,234,567`. +- `group-digits=integer=none|decimal|integer`. This option affects how the grouping into blocks of three is taking blace. +- `per-mode=power|fraction|symbol`. This option allows to specify how `\per` is handled (e.g. in the case of this unit: `\joule\per\mole\per\kelvin`). `fraction` uses the `\frac{}` command, while `symbol` uses a `/` symbol per default (can be changed with `per-symbol`). +- `uncertainty-mode=full|compact|compact-marker|separate`. When a single uncertainty is given, it can be printed in parentheses, e.g. `1.234(5)`, or with a `±` sign, e.g. `1.234 ± 0.005` (use `separate` as option to achieve this). In older versions of `siunitx`, there existed the following flag instead: `separate-uncertainty=true|false` (it might still work in newer versions). +- `exponent-product=\times`. This option allows to specify the product symbol between mantissa and exponent, e.g. `1.23 \times 10^3` or `1.23 \cdot 10^3`. The latter is more common in European countries. This is also affected by the `locale` option. + + + + + +[`siunitx`]: https://ctan.org/pkg/siunitx diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 00000000..65a75d94 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..da53d172 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,86 @@ +--- +layout: home +title: Home +nav_order: 1 +--- +
+ ResultWizard Header Image +
+ +# ResultWizard +{: .fs-9 } + +Think of ResultWizard as the glue
+between Python code & your LaTeX work. + +{: .fs-6 .fw-300 } + +{% capture intro_link %}{% link pages/quickstart.md %}{% endcapture %} +[Quickstart]({{intro_link}}){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } +[Source Code (GitHub)](https://github.com/resultwizard/ResultWizard){: .btn .fs-5 .mb-4 .mb-md-0 } +[PyPI](https://pypi.org/project/resultwizard/){: .btn .fs-5 .mb-4 .mb-md-0 } + + +{: .warning } +ResultWizard is currently fully functional but still in its **alpha stage**, i.e. the API might change. We're happy to receive your feedback until the first stable release. +
Please report any issues on [GitHub](https://github.com/resultwizard/ResultWizard/issues). To get the latest alpha version, you have to install it via +
`pip install resultwizard==1.0.0a2` (otherwise you end up using the older version `0.1`). + +--- + + +## 💥 The problem ResultWizard tries to solve + +Various scientific disciplines deal with huge amounts of experimental data on a daily basis. Oftentimes, this data is processed in Python to calculate important quantities. In the end, these quantities will get published in scientific papers written in LaTeX. You may have manually copied values from the Python console or a Jupyter notebook to a LaTeX document. This **lack of a programmatic interface between Python code & LaTeX** is what `ResultWizard` is addressing. + +## 💡 How does ResultWizard help? + +In a nutshell, export any variables from Python including possible uncertainties and their units. + +```py +# Your Python code +import resultwizard as wiz +wiz.res("Tour Eiffel Height", 330.362019, 0.5, "\m") +wiz.res("g", 9.81, 0.78, "\m/\s^2") +wiz.export("./results.tex") +``` + +This will generate a LaTeX file `results.tex`. Add this file to your LaTeX preamble: + +```latex +% Your LaTeX preamble +\input{./results.tex} +\begin{document} +... +``` + +Then, you can reference the variables in your LaTeX document: + +```latex +% Your LaTeX document +This allowed us to calculate the acceleration due to gravity $g$ as +\begin{align} + g &= \resultG +\end{align} +Therefore, the height of the Eiffel Tower is given by $h = \resultTourEiffelHeight$. +``` + +It will render as follows: + +![result rendered in LaTeX](https://github.com/resultwizard/ResultWizard/assets/37160523/d2b5fcce-fa99-4b6f-b32a-26125e5c6d9b) + + +That's the gist of `ResultWizard`. Of course, there are many more features and customizations available. + + +## Why shouldn't I continue to manually copy-paste values? + +Here's a few reasons why you should consider using `ResultWizard` as programmatic interface instead: + +- _Sync_: By manually copying values, you risk getting your LaTeX document out of sync with your Python code. The latter might have to be adjusted even during the writing process of your paper. And one line of code might change multiple variables that you have to update in your LaTeX document. With `ResultWizard`, you can simply re-export the variables and you're good to go: +- _Significant figures_: `ResultWizard` takes care of significant figures rounding for you, e.g. a result `9.81 ± 0.78` will be rendered as `9.8 ± 0.8` in LaTeX (customizable). + +`ResultWizard` allows you to have your variables in one place: your Python code where you calculated them anyways. **Focus on your research, not on copying values around**. + +[Get started now]({{intro_link}}){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } diff --git a/docs/pages/about.md b/docs/pages/about.md new file mode 100644 index 00000000..dcc3db18 --- /dev/null +++ b/docs/pages/about.md @@ -0,0 +1,38 @@ +--- +layout: default +title: About +permalink: about +nav_order: 4 +--- + +# About +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ + +## Little bit of history +This project started out at the end of 2023 under the name `python2latex` when [*Paul Obernolte*](https://github.com/paul019) was working on reports for the practical parts of the physics curriculum at the University of Heidelberg. In these reports, experimental data was processed in Python and many results had to be included in LaTeX. The manual copy-paste process was error-prone and took too much time, so he started this project. Originally, it just consisted of one single file, that got the job done. + +In 2024, the project was renamed to `ResultWizard` as [*Dominic Plein*](https://github.com/splines) joined the team. Together, we completely redesigned the project, added new features and improved the code quality with linting, tests, CI/CD and a modular clean architecture approach. We set up a branding, published first alpha releases to PyPI and started this documentation. We learned many things along the way and are proud to have published our first Python package. + +We hope that `ResultWizard` will help you save time and make dealing with data in Python and LaTeX more enjoyable. If you have any feedback, please let us know on [GitHub](https://github.com/resultwizard/ResultWizard/issues). If you like the project, consider starring it on GitHub as we're always happy to see that people find our work (we're doing in our free-time) useful. + + +## The future (plans for 2024) + +As of mid-April 2024, we're still in the alpha stage. But the first stable release is not that far away. We want to make sure we ship a solid product you can rely on, so expect a few more months of alpha testing. We're happy to receive your feedback until the first stable release. After this release, we plan to maintain the project (especially with regards to security fixes). But we will probably add new features only sparingly (in an effort to keep the API stable and in the light of our limited free-time resources). + + +## Acknowledgements + +We like to thank the many great people in our surroundings who have helped make this project see the light of the day. We're grateful for the support of our friends, family and colleagues, both mentally and in the form of very concrete feedback as alpha testers. We also want to thank the open-source community for providing us with so many great tools and libraries, among others: [`siunitx`](https://github.com/josephwright/siunitx) by Joseph Wright, [`plum`](https://github.com/beartype/plum) by Wessel Bruinsma and other contributors as well as [`pylint`](https://github.com/pylint-dev/pylint/) by Pierre Sassoulas and others. We also want to thank the whole `Python` and `LaTeX` communities for providing us with such powerful tools to work with in the first place. And the [`just-the-docs`](https://github.com/just-the-docs/just-the-docs) contributors for such an amazing Docs theme. Thank you all! 🙏 diff --git a/docs/pages/quickstart.md b/docs/pages/quickstart.md new file mode 100644 index 00000000..3dde4770 --- /dev/null +++ b/docs/pages/quickstart.md @@ -0,0 +1,192 @@ +--- +layout: default +title: Quickstart +permalink: quickstart +nav_order: 2 +--- + +# Quickstart +{: .no_toc } + +
+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ + + +## ❓ Is this for me? + +**Before you start and to avoid frustration**, let's make sure that `ResultWizard` is the right tool for you: + +- It's not primarily for you, if you're writing your LaTeX code in a web-based editor like Overleaf. `ResultWizard` is a Python package that will export a `.tex` file in the end. You have to include this file in your LaTeX project and the closer your Python code is to your LaTeX document, the easier it is to reference it without having to do anything manually in-between. You could still use `ResultWizard` in your Python code and manually copy the contents of the generated `results.tex` file into Overleaf, but this is not the originally intended workflow. +- The same philosophy applies to Jupyter Notebooks that run in a browser. Instead, you should use a local Jupyter Notebook setup and have your LaTeX project reside next to your Python code. Using VSCode as editor is one way to do this. It has built-in support [for Jupyter Notebooks](https://code.visualstudio.com/docs/datascience/jupyter-notebooks) and with the [LaTeX Workshop extension](https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop) you can easily compile your LaTeX document and see the changes immediately. For a possible setup within WSL, see [this guide](https://github.com/Splines/vscode-latex-wsl-setup). + +Ideally, you'd have a folder structure where `code` (or `python` or `src` or whatever) and `thesis` (or `latex` or `document` or whatever) are folders on the same level. See also the [wiz.export() API]({{site.baseurl}}/api/export). + +Ideally, you also have used the [`siunitx`] LaTeX package beforehand to know how to format units, e.g. `\m\per\s^2`. But don't worry if you haven't, you can still use `ResultWizard` and learn about `siunitx` along the way. You might also want to check out the [siunitx configuration]({{site.baseurl}}/tips/siunitx) page. + + + +## 💻 Installation & prerequisites + +{: .tldr } +> Have a LaTeX toolchain including [`siunitx`] set up and Python `>=3.8` installed.
Then install the `ResultWizard` package via [`pip`]: +> ``` +pip install resultwizard # use `pip install resultwizard==1.0.0a2` in the current alpha stage +``` +> Move on to [Basic usage](#-basic-usage). + + + +### Python package + +You can install `ResultWizard` via [`pip`]: + +``` +pip install resultwizard +``` + +{: .warning } +ResultWizard is currently fully functional but still in its **alpha stage**, i.e. the API might change. We're happy to receive your feedback until the first stable release. +
Please report any issues on [GitHub](https://github.com/resultwizard/ResultWizard/issues). To get the latest alpha version, you have to install it via +
`pip install resultwizard==1.0.0a2` (otherwise you end up using the older version `0.1`). + +Verify you're using the version you intended to install: + +``` +pip show resultwizard | grep Version +``` + +### LaTeX toolchain + +To compile the LaTeX document, you need a working LaTeX toolchain. If you don't have one yet, there are many guides available online for different OS, e.g. on the [LaTeX project website](https://www.latex-project.org/get/). +- For MacOS, you might want to use [MacTex](https://www.tug.org/mactex/). +- For Windows, we recommend [MikTex](https://miktex.org/). +- For Linux (Ubuntu, e.g. also in WSL), we recommend [Tex Live](https://www.tug.org/texlive/)[^1]: +``` +sudo apt install texlive texlive-latex-extra texlive-science +``` + + +No matter what LaTeX distribution you're using, you will have to install the [`siunitx`] LaTeX package. This package is used to format numbers and units in LaTeX, e.g. for units you can use strings like `\kg\per\cm`. In the Tex Live distribution, this package is already included if you have installed `sudo apt texlive-science`. + + +### Checklist + +- [x] Python `>=3.8` installed & `ResultWizard` installed via [`pip`] +- [x] LaTeX toolchain set up, e.g. TeX Live +- [x] [`siunitx`] LaTeX package installed + + + + + + + + +## 🌟 Basic usage + +{: .tldr } +> 1. Import the library in your Python code, declare your results and export them: +>```py +# In your Python code +import resultwizard as wiz +wiz.res("Tour Eiffel Height", 330.362019, 0.5, "\m") # units must be `siunitx` compatible +wiz.export("./results.tex") +``` +> 2. Include the results in your LaTeX document: +>```latex +% In your LaTeX document +\input{./results.tex} +\begin{document} + The height of the Eiffel Tower is given by $h = \resultTourEiffelHeight$. + % also try out: $h = \resultTourEiffelHeight[value]$ +\end{document} +``` + + +### 1. Declare & export variables in Python + +In your Python code, import `ResultWizard` and use the `wiz.res` function to declare your results. Then, export them to a LaTeX file by calling `wiz.export`. For the unit, you must use a [`siunitx`] compatible string, e.g. `\m` for meters or `\kg\per\s^2`. See the [siunitx docs](https://texdoc.org/serve/siunitx/0#page=42) for more information. + +```py +import resultwizard as wiz + +# your complex calculations +# ... +value = 330.362019 # decimal places are made up +uncertainty = 0.5 +wiz.res("Tour Eiffel Height", value, uncertainty, "\m").print() +# will print: TourEiffelHeight = (330.4 ± 0.5) m + +wiz.export("./results.tex") +``` +There are many [more ways to call `wiz.res()`](TODO), try to use IntelliSense (`Ctrl + Space`) in your IDE to see all possibilities. If you want to omit any rounding, pass in values as string, e.g.: +```py +wiz.res("pi", "3.1415").print() # congrats, you found an exact value for pi! +# will print: pi = 3.1415 +wiz.res("Tour Eiffel Height", str(value), str(uncertainty), "\m").print() +# will print: TourEiffelHeight = (330.362019 ± 0.5) m +``` + +You can also use the `wiz.config_init` function to set some defaults for the whole program. See many more [configuration options here](config). +```py +wiz.config_init(sigfigs_fallback=3, identifier="customResult") +# default to 2 and "result" respectively +``` + +If you're working in a *Jupyter Notebook*, please see [this page]({{site.baseurl}}/tips/jupyter) for a suitable configuration of `ResultWizard` that doesn't annoy you with warnings and prints/exports the results automatically. + + +### 2. Include results in LaTeX + +You have either called `wiz.export(.results.tex)` or set up automatic exporting with `wiz.config_init(export_auto_to="./results.tex")`. In any case, you end up with a LaTeX file `./results.tex`. Import it in your LaTeX preamble (you only have to do this once for every new document you create): + +```latex +% Your LaTeX preamble +\input{./results.tex} +\begin{document} +... +``` + +Then, you can reference the variables in your LaTeX document: + +```latex +% Your LaTeX document +This allowed us to calculate the acceleration due to gravity $g$ as +\begin{align} + g &= \resultG +\end{align} +Therefore, the height of the Eiffel Tower is given by $h = \resultTourEiffelHeight$. +``` + +It will render as follows (given respective values for `g` and `Tour Eiffel Height` are exported): + +![result rendered in LaTeX](https://github.com/resultwizard/ResultWizard/assets/37160523/d2b5fcce-fa99-4b6f-b32a-26125e5c6d9b) + +If you set up your IDE or your LaTeX editor properly, you can use IntelliSense (`Ctrl + Space`) here as well to see all available results, e.g. type `\resultTo`. Changing the value in Python and re-exporting will automatically update the value in your LaTeX document[^2]. + +

+Also try out the following syntax: + +```latex +% Your LaTeX document +The unit of $h$ is $\resultTourEiffelHeight[unit]$ and its value is $\resultTourEiffelHeight[value]$. +``` + +Use `\resultTourEiffelHeight[x]` to get a message printed in your LaTeX document informing you about the possible strings you can use instead of `x` (e.g. `withoutUnit`, `value` etc.). + +--- + +[^1]: For differences between texlive packages, see [this post](https://tex.stackexchange.com/a/504566/). For Ubuntu users, there's also an installation guide available [here](https://wiki.ubuntuusers.de/TeX_Live/#Installation). If you're interested to see how Tex Live can be configured in VSCode inside WSL, see [this post](https://github.com/Splines/vscode-latex-wsl-setup). +[^2]: You have to recompile your LaTeX document, of course. But note that you can set up your LaTeX editor / IDE to recompile the PDF automatically for you as soon as any files, like `results.tex` change. For VSCode, you can use the [LaTeX Workshop extension](https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop) for this purpose, see a guide [here](https://github.com/Splines/vscode-latex-wsl-setup). In the best case, you only have to update a variable in your Python code (and run that code) and see the change in your PDF document immediately. + +[`siunitx`]: https://ctan.org/pkg/siunitx +[`pip`]: https://pypi.org/project/resultwizard diff --git a/docs/pages/trouble.md b/docs/pages/trouble.md new file mode 100644 index 00000000..ff1951dc --- /dev/null +++ b/docs/pages/trouble.md @@ -0,0 +1,94 @@ +--- +layout: default +title: Troubleshooting +permalink: trouble +nav_order: 3 +--- + +# Troubleshooting +{: .no_toc } + +

+ + Content + + {: .text-delta } + +- TOC +{:toc} + +
+ +There might be several reasons for your LaTeX document not building. **Try to find the root cause** by looking at the **log file** of your LaTeX compiler (sometimes you have to scroll way up to find the error responsible for the failing build). Also open the [exported]({{site.baseurl}}/api/export) `results.tex` file to see if your editor/IDE shows any errors there. You might encounter one of the following problems. Please make sure to try the solutions provided here before opening an [issue on GitHub](https://github.com/resultwizard/ResultWizard/issues). + + + +## Package siunitx: Invalid number + +{: .tldr} +You have probably specified **multiple uncertainties** in `wiz.res()`, right? If this is the case and you get this error, you have an **old version of `siunitx`** installed. Please update it (recommended) or use the `siunitx_fallback` option in the [`config_init`]({{site.baseurl}}/api/config) method. + +In version [`v3.1.0 (2022-04-25)`](https://github.com/josephwright/siunitx/blob/main/CHANGELOG.md#v310---2022-04-25), `siunitx` introduced "support for multiple uncertainty values in both short and long form input". We make use of this feature in `ResultWizard` when you specify multiple uncertainties for a result. + +Unfortunately, it may be the case that you're using an older version of `siunitx` that doesn't ship with this feature yet. Especially if you've installed LaTeX via a package manager (e.g. you installed `siunitx` via `sudo apt install texlive-science`). To determine your `siunitx` version, include the following line in your LaTeX document: + +```latex +\listfiles % add this before \begin{document}, i.e. in your LaTeX preamble +``` + +Then, compile your document and check the log for the version of `siunitx`. +
If it's **older than `v3.1.0 (2022-04-25)`**, don't despair. We have two solutions for you: + +### Solution 1: Don't update `siunitx` and stick with your old version + +Sure, fine, we won't force you to update `siunitx` (although we'd recommend it). To keep using your old version, specify the following key in the `config_init` method: + +```python +wiz.config_init(siunitx_fallback=True) +``` + +Note that with this "solution", you won't be able to fully customize the output of the result in your LaTeX document. For example, we will use a `±` between the value and the uncertainty, e.g. `3.14 ± 0.02`. You won't be able to change this in your `sisetup` by specifying + +```latex +\sisetup{separate-uncertainty=false} +``` + +to get another format like `3.14(2)`. There are also some other `siunitx` options that won't probably work with `ResultWizard`, e.g. [`exponent-product`](https://texdoc.org/serve/siunitx/0#page=29). If you're fine with this, go ahead and use the `siunitx_fallback` option. If not, consider updating `siunitx` to at least version `v3.1.0`. + +### Solution 2: Update `siunitx` (recommended, but more effort) + +How the update process works depends on your LaTeX distribution and how you installed it. E.g. you might be using `TeX Live` on `Ubuntu` and installed packages via `apt`, e.g. `sudo apt install texlive-science` (which includes the LaTeX `siunitx`). These pre-built packages are often outdated, e.g. for Ubuntu 22.04 LTS (jammy), the `siunitx` version that comes with the `texlive-science` package is `3.0.4`. Therefore, you might have to update `siunitx` manually. See an overview on how to install individual LaTeX packages on Linux [here](https://tex.stackexchange.com/a/73017/). + +A quick solution might be to simply install a new version of `siunitx` manually to your system. There's a great and short Ubuntu guide on how to install LaTeX packages manually [here](https://help.ubuntu.com/community/LaTeX#Installing_packages_manually). The following commands are based on this guide. We will download the version `3.1.11 (2022-12-05)` from GitHub (this is the last version before `3.2` where things might get more complicated to install) and install it locally. Don't be scared, do it one step at a time and use the power of GPTs and search engines in case you're stuck. Execute the following commands in your terminal: + +```sh +# Install "unzip", a tool to extract zip files +sudo apt install unzip + +# Download v3.1.11 of siunitx from GitHub +curl -L https://github.com/josephwright/siunitx/releases/download/v3.1.11/siunitx-ctan.zip > siunitx-ctan-3.1.11.zip + +# Unzip the file +unzip ./siunitx-ctan-3.1.11.zip +cd siunitx/ + +# Run LaTeX on the .ins file to generate a usable .sty file +# (LaTeX needs the .sty file to know what to do with the \usepackage{siunitx} +# command in your LaTeX preamble.) +latex siunitx.ins + +# Create a new directory in your home directory +# to store the new package .sty file +mkdir -p ~/texmf/tex/latex/siunitx # use any location you want, but this one is common +cp siunitx.sty ~/texmf/tex/latex/siunitx/ + +# Make LaTeX recognize the new package by pointing it to the new directory +texhash ~/texmf/ +``` + +🙌 Done. Try to recompile your LaTeX document again. You should see version `v3.1.11` of `siunitx` in the log file. And it should build. Don't forget to remove the `\listfiles` from your LaTeX document to avoid cluttering your log file (which is ironic for LaTeX, we know). + +In case you don't wan't the new siunitx version anymore, just run the following command to remove the `.sty` file. LaTeX will then use the version of siunitx it finds somewhere else in your system (which is probably the outdated one you had before). +```sh +rm ~/texmf/tex/latex/siunitx/siunitx.sty +``` diff --git a/pyproject.toml b/pyproject.toml index fe027305..7113c443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,17 +7,17 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] -include = ["valuewizard"] -namespaces = false +include = ["api", "application", "domain", "resultwizard"] +namespaces = true [project] -name = "valuewizard" -version = "0.1" +name = "resultwizard" +version = "1.0.0-alpha.2" authors = [ - { name = "Paul Obernolte (paul019)", email = "todo@todo.de" }, - { name = "Dominic Plein (Splines)", email = "todo@todo.de" }, + { name = "Paul Obernolte (paul019)" }, + { name = "Dominic Plein (Splines)" }, ] -description = "Python to LaTeX variable conversion" +description = "Intelligent interface between Python-computed values and your LaTeX work" keywords = [ "latex", "variable", @@ -29,16 +29,16 @@ keywords = [ "jupyter", ] readme = "README.md" -requires-python = ">=3.9" -license = { file = "LICENSE" } +requires-python = ">=3.8" # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#classifiers classifiers = [ - "Development Status :: 1 - Planning", + "Development Status :: 3 - Alpha", "Topic :: Scientific/Engineering :: Physics", "Topic :: Software Development :: Build Tools", "Topic :: Text Processing :: Markup :: LaTeX", "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -47,10 +47,10 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/paul019/ValueWizard" -Repository = "https://github.com/paul019/ValueWizard" -Issues = "https://github.com/paul019/ValueWizard/issues" -Changelog = "https://github.com/paul019/ValueWizard/blob/main/CHANGELOG.md" +Homepage = "https://resultwizard.github.io/ResultWizard/" +Repository = "https://github.com/resultwizard/ResultWizard" +Issues = "https://github.com/resultwizard/ResultWizard/issues" +Changelog = "https://github.com/resultwizard/ResultWizard/blob/main/CHANGELOG.md" # TODO: Add these checks back [tool.pylint."messages control"] @@ -58,6 +58,8 @@ disable = [ "missing-module-docstring", "missing-function-docstring", "missing-class-docstring", + "fixme", + "too-few-public-methods", ] [tool.pylint.format] diff --git a/src/README.md b/src/README.md index bad7bfd6..d62045b4 100644 --- a/src/README.md +++ b/src/README.md @@ -4,7 +4,7 @@ We use the clean architecture paradigm. Modules in outer layers are allowed to i From outer layer to inner layer: -- `valuewizard/`: Entrypoint for the PIP package; mainly only import statements +- `resultwizard/`: Entrypoint for the PIP package; mainly only import statements - `api/`: User-facing API, e.g. `res()` and `export()` method - `application/`: Application code that uses the domain logic to solve specific problems - `domain/`: Domain logic, e.g. definition of what a `value`, how things are represented internally diff --git a/src/api/config.py b/src/api/config.py new file mode 100644 index 00000000..78648bc0 --- /dev/null +++ b/src/api/config.py @@ -0,0 +1,173 @@ +import decimal +from typing import Union, cast +from dataclasses import dataclass + +from api.res import _res_cache +from application.stringifier import StringifierConfig +from application.rounder import RoundingConfig +from application import error_messages + + +@dataclass +# pylint: disable-next=too-many-instance-attributes +class Config: + """Configuration settings for the application. + + Args: + sigfigs (int): The number of significant figures to round to. + decimal_places (int): The number of decimal places to round to. + print_auto (bool): Whether to print each result directly to the console. + export_auto_to (str): If not empty, each `res()` call will automatically + export all results to the file you specify with this keyword. You can + still use the `export()` method to export results to other files as well. + This option might be particularly useful when working in a Jupyter + notebook. This way, you don't have to call `export()` manually each time + you want to export results. + min_exponent_for_non_scientific_notation (int): The minimum exponent + for non-scientific notation. + max_exponent_for_non_scientific_notation (int): The maximum exponent + for non-scientific notation. + identifier (str): The identifier for the result variable in LaTeX. This + identifier will be prefix each result variable name. + sigfigs_fallback (int): The number of significant figures to use as a + fallback if other rounding rules don't apply. + decimal_places_fallback (int): The number of decimal places to use as + a fallback if other rounding rules don't apply. + siunitx_fallback (bool): Whether to use a fallback logic such that LaTeX + commands still work with an older version of siunitx. See + the docs for more information: TODO. + precision (int): The precision to use for the decimal module. Defaults to + 40 in ResultsWizard. You may have to increase this if you get the error + "Your precision is set too low". + ignore_result_overwrite (bool): If True, you won't get any warnings if you + overwrite a result with the same name. Defaults to False. This option + might be useful for Jupyter notebooks when you want to re-run cells + without getting any warnings that a result with the same name already + exists. + """ + + sigfigs: int + decimal_places: int + print_auto: bool + export_auto_to: str + min_exponent_for_non_scientific_notation: int + max_exponent_for_non_scientific_notation: int + identifier: str + table_identifier: str + sigfigs_fallback: int + decimal_places_fallback: int + siunitx_fallback: bool + precision: int + ignore_result_overwrite: bool + + def to_stringifier_config(self) -> StringifierConfig: + return StringifierConfig( + self.min_exponent_for_non_scientific_notation, + self.max_exponent_for_non_scientific_notation, + self.identifier, + self.table_identifier, + ) + + def to_rounding_config(self) -> RoundingConfig: + return RoundingConfig( + self.sigfigs, + self.decimal_places, + self.sigfigs_fallback, + self.decimal_places_fallback, + ) + + +def _check_config() -> None: + if configuration.sigfigs > -1 and configuration.decimal_places > -1: + raise ValueError(error_messages.SIGFIGS_AND_DECIMAL_PLACES_AT_SAME_TIME) + + if configuration.sigfigs_fallback > -1 and configuration.decimal_places_fallback > -1: + raise ValueError(error_messages.SIGFIGS_FALLBACK_AND_DECIMAL_PLACES_FALLBACK_AT_SAME_TIME) + + if configuration.sigfigs_fallback <= -1 and configuration.decimal_places_fallback <= -1: + raise ValueError( + error_messages.ONE_OF_SIGFIGS_FALLBACK_AND_DECIMAL_PLACES_FALLBACK_MUST_BE_SET + ) + + if configuration.sigfigs == 0: + raise ValueError(error_messages.CONFIG_SIGFIGS_VALID_RANGE) + + if configuration.sigfigs_fallback == 0: + raise ValueError(error_messages.CONFIG_SIGFIGS_FALLBACK_VALID_RANGE) + + +# pylint: disable-next=too-many-arguments +def config_init( + sigfigs: int = -1, # -1: "per default use rounding rules instead" + decimal_places: int = -1, # -1: "per default use rounding rules instead" + print_auto: bool = False, + export_auto_to: str = "", + min_exponent_for_non_scientific_notation: int = -2, + max_exponent_for_non_scientific_notation: int = 3, + identifier: str = "result", + table_identifier: str = "table", + sigfigs_fallback: int = 2, + decimal_places_fallback: int = -1, # -1: "per default use sigfigs as fallback instead" + siunitx_fallback: bool = False, + precision: int = 100, + ignore_result_overwrite: bool = False, +) -> None: + global configuration # pylint: disable=global-statement + + decimal.DefaultContext.prec = precision + decimal.setcontext(decimal.DefaultContext) + + configuration = Config( + sigfigs, + decimal_places, + print_auto, + export_auto_to, + min_exponent_for_non_scientific_notation, + max_exponent_for_non_scientific_notation, + identifier, + table_identifier, + sigfigs_fallback, + decimal_places_fallback, + siunitx_fallback, + precision, + ignore_result_overwrite, + ) + + _res_cache.configure(not ignore_result_overwrite) + + _check_config() + + +configuration = cast(Config, None) # pylint: disable=invalid-name +config_init() + + +def config( + sigfigs: Union[int, None] = None, + decimal_places: Union[int, None] = None, + print_auto: Union[bool, None] = None, + sigfigs_fallback: Union[int, None] = None, + decimal_places_fallback: Union[int, None] = None, +): + if sigfigs is not None: + configuration.sigfigs = sigfigs + if sigfigs > -1 and decimal_places is None: + configuration.decimal_places = -1 + if decimal_places is not None: + configuration.decimal_places = decimal_places + if decimal_places > -1 and sigfigs is None: + configuration.sigfigs = -1 + + if print_auto is not None: + configuration.print_auto = print_auto + + if sigfigs_fallback is not None: + configuration.sigfigs_fallback = sigfigs_fallback + if sigfigs_fallback > -1 and decimal_places_fallback is None: + configuration.decimal_places_fallback = -1 + if decimal_places_fallback is not None: + configuration.decimal_places_fallback = decimal_places_fallback + if decimal_places_fallback > -1 and sigfigs_fallback is None: + configuration.sigfigs_fallback = -1 + + _check_config() diff --git a/src/api/console_stringifier.py b/src/api/console_stringifier.py new file mode 100644 index 00000000..e3a77dbc --- /dev/null +++ b/src/api/console_stringifier.py @@ -0,0 +1,48 @@ +from domain.result import Result +from application.stringifier import Stringifier + + +class ConsoleStringifier(Stringifier): + plus_minus = "±" + negative_sign = "-" + positive_sign = "" + + left_parenthesis = "(" + right_parenthesis = ")" + + value_prefix = "" + value_suffix = "" + + uncertainty_name_prefix = " (" + uncertainty_name_suffix = ")" + + scientific_notation_prefix = "e" + scientific_notation_suffix = "" + + unit_prefix = " " + unit_suffix = "" + + def result_to_str(self, result: Result): + """ + Returns the result as human-readable string. + """ + + return f"{result.name} = {self.create_str(result.value, result.uncertainties, result.unit)}" + + def _modify_unit(self, unit: str) -> str: + """ + Returns the modified unit. + """ + unit = ( + unit.replace(r"\squared", "^2") + .replace(r"\cubed", "^3") + .replace("\\per\\", "/") + .replace(r"\per", "/") + .replace("\\", " ") + .strip() + ) + + if unit[0] == "/": + unit = f"1{unit}" + + return unit diff --git a/src/api/export.py b/src/api/export.py index 5227be22..8e673ec3 100644 --- a/src/api/export.py +++ b/src/api/export.py @@ -1,5 +1,8 @@ -from application.cache import _res_cache -from application.latexer import _LaTeXer +from typing import Set +from api.latexer import get_latexer, get_table_latexer +from api.res import _res_cache +import api.config as c +from application.helpers import Helpers def export(filepath: str): @@ -7,26 +10,82 @@ def export(filepath: str): Rounds all results according to the significant figures and writes them to a .tex file at the given filepath. """ + return _export(filepath, print_completed=True) + + +def _export(filepath: str, print_completed: bool): results = _res_cache.get_all_results() - print(f"Processing {len(results)} result(s)") + tables = _res_cache.get_all_tables() + + if print_completed: + print(f"Processing {len(results)} result(s)") # Round and convert to LaTeX commands - cmds = [ + lines = [ r"%", r"% In your `main.tex` file, put this line directly before `\begin{document}`:", r"% \input{" + filepath.split("/")[-1].split(".")[0] + r"}", r"%", r"", r"% Import required package:", + r"\usepackage{siunitx}", r"\usepackage{ifthen}", r"", - r"% Define commands to print the results:", ] + + latexer = get_latexer() + table_latexer = get_table_latexer() + + uncertainty_names = set() + result_lines = [] for result in results: - result_str = _LaTeXer.result_to_latex_cmd(result) - cmds.append(result_str) + uncertainty_names.update(u.name for u in result.uncertainties if u.name != "") + result_str = latexer.result_to_latex_cmd(result) + result_lines.append(result_str) + + table_lines = [] + for table in tables: + table_str = table_latexer.table_to_latex_cmd(table) + table_lines.append(table_str) + + if not c.configuration.siunitx_fallback: + siunitx_setup = _uncertainty_names_to_siunitx_setup(uncertainty_names) + if siunitx_setup != "": + lines.append("% Commands to correctly print the uncertainties in siunitx:") + lines.append(siunitx_setup) + lines.append("") + + lines.append("% Commands to print the results. Use them in your document.") + lines.extend(result_lines) + + lines.append("") + + lines.append("% Commands to print the tables. Use them in your document.") + lines.extend(table_lines) # Write to file with open(filepath, "w", encoding="utf-8") as f: - f.write("\n".join(cmds)) - print(f'Exported to "{filepath}"') + f.write("\n".join(lines)) + if print_completed: + print(f'Exported to "{filepath}"') + + +def _uncertainty_names_to_siunitx_setup(uncert_names: Set[str]) -> str: + """ + Returns the preamble for the LaTeX document to use the siunitx package. + """ + if len(uncert_names) == 0: + return "" + + cmd_names = [] + cmds = [] + for name in uncert_names: + cmd_name = f"\\Uncert{Helpers.capitalize(name)}" + cmd_names.append(cmd_name) + cmds.append(rf"\NewDocumentCommand{{{cmd_name}}}{{}}{{_{{\text{{{name}}}}}}}") + + string = "\n".join(cmds) + string += "\n" + string += rf"\sisetup{{input-digits=0123456789{''.join(cmd_names)}}}" + + return string diff --git a/src/api/latexer.py b/src/api/latexer.py new file mode 100644 index 00000000..3ad57870 --- /dev/null +++ b/src/api/latexer.py @@ -0,0 +1,23 @@ +import api.config as c +from application.latex_better_siunitx_stringifier import LatexBetterSiunitxStringifier +from application.latex_commandifier import LatexCommandifier +from application.latex_stringifier import LatexStringifier +from application.stringifier import Stringifier +from application.tables.table_latex_commandifier import TableLatexCommandifier + + +def get_latexer() -> LatexCommandifier: + return LatexCommandifier(_choose_latex_stringifier()) + + +def get_table_latexer() -> TableLatexCommandifier: + return TableLatexCommandifier(_choose_latex_stringifier()) + + +def _choose_latex_stringifier() -> Stringifier: + use_fallback = c.configuration.siunitx_fallback + stringifier_config = c.configuration.to_stringifier_config() + + if use_fallback: + return LatexStringifier(stringifier_config) + return LatexBetterSiunitxStringifier(stringifier_config) diff --git a/src/api/parsers.py b/src/api/parsers.py new file mode 100644 index 00000000..c54f6511 --- /dev/null +++ b/src/api/parsers.py @@ -0,0 +1,220 @@ +from typing import Union, List, Tuple +from decimal import Decimal + +from application.helpers import Helpers +from application import error_messages +from domain.value import Value +from domain.uncertainty import Uncertainty + + +def check_if_number_string(value: str) -> None: + """Raises a ValueError if the string is not a valid number.""" + try: + float(value) + except ValueError as exc: + raise ValueError(error_messages.STRING_MUST_BE_NUMBER.format(value=value)) from exc + + +def parse_name(name: str) -> str: + """Parses the name.""" + if not isinstance(name, str): + raise TypeError(error_messages.FIELD_MUST_BE_STRING.format(field="`name`", type=type(name))) + + if name == "": + raise ValueError(error_messages.FIELD_MUST_NOT_BE_EMPTY.format(field="`name`")) + + name = ( + name.replace("ä", "ae") + .replace("Ä", "Ae") + .replace("ö", "oe") + .replace("Ö", "Oe") + .replace("ü", "ue") + .replace("Ü", "Ue") + .replace("ß", "ss") + # we use "SS" instead of "Ss" as replacement for "ẞ" + # since "ẞ" is only allowed in uppercase names in German + .replace("ẞ", "SS") + ) + + parsed_name = "" + next_char_upper = False + ignored_chars = set() + + while name != "": + char = name[0] + + if char.isalpha(): + if next_char_upper: + parsed_name += char.upper() + next_char_upper = False + else: + parsed_name += char + elif char.isdigit(): + num_digits = _greedily_count_digits_at_start_of_str(name) + word = Helpers.number_to_word(int(name[:num_digits])) + if parsed_name != "": + word = Helpers.capitalize(word) + parsed_name += word + next_char_upper = True + name = name[num_digits:] # Skip the parsed digits + continue + elif char in [" ", "_", "-"]: + next_char_upper = True + else: + ignored_chars.add(char) + + name = name[1:] + + if len(ignored_chars) > 0: + print(error_messages.INVALID_CHARS_IGNORED.format(chars=", ".join(ignored_chars))) + + if parsed_name == "": + raise ValueError(error_messages.STRING_EMPTY_AFTER_IGNORING_INVALID_CHARS) + + return parsed_name + + +def _greedily_count_digits_at_start_of_str(word: str) -> int: + """Counts the number of digits at the start of the string.""" + num_digits = 0 + for c in word: + if c.isdigit(): + num_digits += 1 + else: + break + return num_digits + + +def parse_unit(unit: str) -> str: + """Parses the unit.""" + if not isinstance(unit, str): + raise TypeError(error_messages.FIELD_MUST_BE_STRING.format(field="`unit`", type=type(unit))) + + # TODO: maybe add some basic checks to catch siunitx errors, e.g. + # unsupported symbols etc. But maybe leave this to LaTeX and just return + # the LaTeX later on. But catching it here would be more user-friendly, + # as the user would get immediate feedback and not only once they try to + # export the results. + return unit + + +def parse_sigfigs(sigfigs: Union[int, None]) -> Union[int, None]: + """Parses the number of sigfigs.""" + if sigfigs is None: + return None + + if not isinstance(sigfigs, int): + raise TypeError( + error_messages.FIELD_MUST_BE_INT.format(field="`sigfigs`", type=type(sigfigs)) + ) + + if sigfigs < 1: + raise ValueError(error_messages.FIELD_MUST_BE_POSITIVE.format(field="`sigfigs`")) + + return sigfigs + + +def parse_decimal_places(decimal_places: Union[int, None]) -> Union[int, None]: + """Parses the number of sigfigs.""" + if decimal_places is None: + return None + + if not isinstance(decimal_places, int): + raise TypeError( + error_messages.FIELD_MUST_BE_INT.format( + field="`decimal_places`", type=type(decimal_places) + ) + ) + + if decimal_places < 0: + raise ValueError(error_messages.FIELD_MUST_BE_NON_NEGATIVE.format(field="`decimal_places`")) + + return decimal_places + + +def parse_value(value: Union[float, int, str, Decimal]) -> Value: + """Converts the value to a _Value object.""" + if not isinstance(value, (float, int, str, Decimal)): + raise TypeError(error_messages.VALUE_TYPE.format(field="`value`", type=type(value))) + + if isinstance(value, str): + check_if_number_string(value) + return parse_exact_value(value) + + return Value(Decimal(value)) + + +def parse_exact_value(value: str) -> Value: + # Determine min exponent: + exponent_offset = 0 + value_str = value + if "e" in value_str: + exponent_offset = int(value_str[value_str.index("e") + 1 :]) + value_str = value_str[0 : value_str.index("e")] + if "." in value: + decimal_places = len(value_str) - value_str.index(".") - 1 + min_exponent = -decimal_places + exponent_offset + else: + min_exponent = exponent_offset + + return Value(Decimal(value), min_exponent) + + +def parse_uncertainties( + uncertainties: Union[ + float, + int, + str, + Decimal, + Tuple[Union[float, int, str, Decimal], str], + List[Union[float, int, str, Decimal, Tuple[Union[float, int, str, Decimal], str]]], + ] +) -> List[Uncertainty]: + """Converts the uncertainties to a list of _Uncertainty objects.""" + uncertainties_res = [] + + # no list, but a single value was given + if isinstance(uncertainties, (float, int, str, Decimal, Tuple)): + uncertainties = [uncertainties] + + for uncert in uncertainties: + if isinstance(uncert, (float, int, str, Decimal)): + uncertainties_res.append(Uncertainty(_parse_uncertainty_value(uncert))) + + elif isinstance(uncert, Tuple): + if not isinstance(uncert[0], (float, int, str, Decimal)): + raise TypeError( + error_messages.VALUE_TYPE.format( + field="First argument of uncertainty-tuple", type=type(uncert[0]) + ) + ) + uncertainties_res.append( + Uncertainty(_parse_uncertainty_value(uncert[0]), parse_name(uncert[1])) + ) + + else: + raise TypeError( + error_messages.UNCERTAINTIES_MUST_BE_TUPLES_OR.format(type=type(uncert)) + ) + + return uncertainties_res + + +def _parse_uncertainty_value(value: Union[float, int, str, Decimal]) -> Value: + """Parses the value of an uncertainty.""" + + if isinstance(value, str): + try: + check_if_number_string(value) + except Exception as exc: + msg = error_messages.STRING_MUST_BE_NUMBER.format(value=value) + msg += ". " + error_messages.UNIT_NOT_PASSED_AS_KEYWORD_ARGUMENT + raise ValueError(msg) from exc + return_value = parse_exact_value(value) + else: + return_value = Value(Decimal(value)) + + if return_value.get() <= 0: + raise ValueError(error_messages.FIELD_MUST_BE_POSITIVE.format(field="Uncertainty")) + + return return_value diff --git a/src/api/printable_result.py b/src/api/printable_result.py index 663b6826..87189598 100644 --- a/src/api/printable_result.py +++ b/src/api/printable_result.py @@ -1,16 +1,24 @@ -from domain.result import _Result -from api.stringifier import Stringifier -from application.latexer import _LaTeXer +from api.console_stringifier import ConsoleStringifier +import api.config as c +from api.latexer import get_latexer +from domain.result import Result class PrintableResult: - def __init__(self, result: _Result): + def __init__(self, result: Result): self._result = result def print(self): """Prints the result to the console.""" - print(Stringifier.result_to_str(self._result)) + stringifier = ConsoleStringifier(c.configuration.to_stringifier_config()) + print(stringifier.result_to_str(self._result)) - def get_latex_str(self) -> str: - """Returns LaTeX string.""" - return _LaTeXer.result_to_latex_str(self._result) + def to_latex_str(self) -> str: + """Converts the result to a string that can be used in LaTeX documents. + + Note that you might also want to use the `export` method to export + all your results to a file, which can then be included in your LaTeX + document. + """ + latexer = get_latexer() + return latexer.result_to_latex_str(self._result) diff --git a/src/api/res.py b/src/api/res.py index 38203ca2..b630947f 100644 --- a/src/api/res.py +++ b/src/api/res.py @@ -1,253 +1,121 @@ +from decimal import Decimal from typing import Union, List, Tuple -from plum import dispatch, overload from api.printable_result import PrintableResult +from api import parsers from application.cache import _res_cache -from application.rounder import _Rounder -from application.helpers import _Helpers -from domain.result import _Result -from domain.value import _Value -from domain.uncertainty import _Uncertainty +from application.rounder import Rounder +from application import error_messages +from domain.result import Result +# "Wrong" import position to avoid circular imports +from api.export import _export # pylint: disable=wrong-import-position,ungrouped-imports +import api.config as c # pylint: disable=wrong-import-position,ungrouped-imports -# TODO: import types from typing to ensure backwards compatibility down to Python 3.8 -# TODO: use pydantic instead of manual and ugly type checking -# see: https://docs.pydantic.dev/latest/ -# This way we can code as if the happy path is the only path, and let pydantic -# handle the error checking and reporting. - - -@overload -def res( - name: str, - value: Union[float, str], - unit: str = "", - sigfigs: Union[int, None] = None, - decimal_places: Union[int, None] = None, -) -> PrintableResult: - return res(name, value, [], unit, sigfigs, decimal_places) - - -@overload -def res( - name: str, - value: Union[float, str], - uncert: Union[ +# pylint: disable-next=too-many-arguments, too-many-locals +def _res( + name: Union[None, str], + value: Union[float, int, str, Decimal], + uncerts: Union[ float, + int, str, - Tuple[Union[float, str], str], - List[Union[float, str, Tuple[Union[float, str], str]]], - None + Decimal, + Tuple[Union[float, int, str, Decimal], str], + List[Union[float, int, str, Decimal, Tuple[Union[float, int, str, Decimal], str]]], + None, ] = None, + unit: str = "", + sys: Union[float, int, str, Decimal, None] = None, + stat: Union[float, int, str, Decimal, None] = None, sigfigs: Union[int, None] = None, decimal_places: Union[int, None] = None, -) -> PrintableResult: - return res(name, value, uncert, "", sigfigs, decimal_places) +) -> [str, Result]: + # Verify user input + if sigfigs is not None and decimal_places is not None: + raise ValueError(error_messages.SIGFIGS_AND_DECIMAL_PLACES_AT_SAME_TIME) + if sigfigs is not None and isinstance(value, str): + raise ValueError(error_messages.SIGFIGS_AND_EXACT_VALUE_AT_SAME_TIME) -@overload -def res( - name: str, - value: Union[float, str], - sigfigs: Union[int, None] = None, - decimal_places: Union[int, None] = None, -) -> PrintableResult: - return res(name, value, [], "", sigfigs, decimal_places) + if decimal_places is not None and isinstance(value, str): + raise ValueError(error_messages.DECIMAL_PLACES_AND_EXACT_VALUE_AT_SAME_TIME) + sys_or_stat_specified = sys is not None or stat is not None + if uncerts is not None and sys_or_stat_specified: + raise ValueError(error_messages.UNCERT_AND_SYS_STAT_AT_SAME_TIME) -@overload -def res( - name: str, - value: Union[float, str], - sys: float, - stat: float, - unit: str = "", - sigfigs: Union[int, None] = None, - decimal_places: Union[int, None] = None, -) -> PrintableResult: - return res(name, value, [(sys, "sys"), (stat, "stat")], unit, sigfigs, decimal_places) + if sys_or_stat_specified: + uncerts = [] + if sys is not None: + uncerts.append((sys, "sys")) + if stat is not None: + uncerts.append((stat, "stat")) + + if uncerts is None: + uncerts = [] + + # Parse user input + if name is not None: + name_res = parsers.parse_name(name) + else: + name_res = "" + value_res = parsers.parse_value(value) + uncertainties_res = parsers.parse_uncertainties(uncerts) + unit_res = parsers.parse_unit(unit) + sigfigs_res = parsers.parse_sigfigs(sigfigs) + decimal_places_res = parsers.parse_decimal_places(decimal_places) + + # Assemble the result + result = Result( + name_res, value_res, unit_res, uncertainties_res, sigfigs_res, decimal_places_res + ) + Rounder.round_result(result, c.configuration.to_rounding_config()) + + return name_res, result -@overload def res( name: str, - value: Union[float, str], - uncert: Union[ + value: Union[float, int, str, Decimal], + uncerts: Union[ float, + int, str, - Tuple[Union[float, str], str], - List[Union[float, str, Tuple[Union[float, str], str]]], - None + Decimal, + Tuple[Union[float, int, str, Decimal], str], + List[Union[float, int, str, Decimal, Tuple[Union[float, int, str, Decimal], str]]], + None, ] = None, unit: str = "", + sys: Union[float, int, str, Decimal, None] = None, + stat: Union[float, int, str, Decimal, None] = None, sigfigs: Union[int, None] = None, decimal_places: Union[int, None] = None, ) -> PrintableResult: - if uncert is None: - uncert = [] - - # Parse user input - name_res = _parse_name(name) - value_res = _parse_value(value) - uncertainties_res = _parse_uncertainties(uncert) - unit_res = _parse_unit(unit) - sigfigs_res = _parse_sigfigs(sigfigs) - decimal_places_res = _parse_decimal_places(decimal_places) - - # Assemble the result - result = _Result( - name_res, value_res, unit_res, uncertainties_res, sigfigs_res, decimal_places_res - ) - _Rounder.round_result(result) - _res_cache.add(name, result) - - return PrintableResult(result) - - -# Hack for method "overloading" in Python -# see https://beartype.github.io/plum/integration.html -# This is a good writeup: https://stackoverflow.com/a/29091980/ -@dispatch -def res(*args, **kwargs) -> object: - # This method only scans for all `overload`-decorated methods - # and properly adds them as Plum methods. - pass - - -def _check_if_number_string(value: str) -> None: - """Raises a ValueError if the string is not a valid number.""" - try: - float(value) - except ValueError as exc: - raise ValueError(f"String value must be a valid number, not {value}") from exc - - -def _parse_name(name: str) -> str: - """Parses the name.""" - if not isinstance(name, str): - raise TypeError(f"`name` must be a string, not {type(name)}") - - name = ( - name.replace("ä", "ae") - .replace("ö", "oe") - .replace("ü", "ue") - .replace("Ä", "Ae") - .replace("Ö", "Oe") - .replace("Ü", "Ue") - .replace("ß", "ss") - ) - - parsed_name = "" - next_chat_upper = False - - for char in name: - if char.isalpha(): - if next_chat_upper: - parsed_name += char.upper() - next_chat_upper = False - else: - parsed_name += char - elif char.isdigit(): - digit = _Helpers.number_to_word(int(char)) - if parsed_name == "": - parsed_name += digit - else: - parsed_name += digit[0].upper() + digit[1:] - next_chat_upper = True - elif char in [" ", "_", "-"]: - next_chat_upper = True - - return parsed_name - + """ + Declares your result. Give it a name and a value. You may also optionally provide + uncertainties (via `uncert` or `sys`/`stat`) and a unit in `siunitx` format. -def _parse_unit(unit: str) -> str: - """Parses the unit.""" - if not isinstance(unit, str): - raise TypeError(f"`unit` must be a string, not {type(unit)}") + You may additionally specify the number of significant figures or decimal places + to round this specific result to, irrespective of your global configuration. - # TODO: maybe add some basic checks to catch siunitx errors, e.g. - # unsupported symbols etc. But maybe leave this to LaTeX and just return - # the LaTeX later on. But catching it here would be more user-friendly, - # as the user would get immediate feedback and not only once they try to - # export the results. - return unit + TODO: provide a link to the docs for more information and examples. + """ + name_res, result = _res(name, value, uncerts, unit, sys, stat, sigfigs, decimal_places) + # Add to cache + _res_cache.add(name_res, result) -def _parse_sigfigs(sigfigs: Union[int, None]) -> Union[int, None]: - """Parses the number of sigfigs.""" - if sigfigs is None: - return None + # Print automatically + printable_result = PrintableResult(result) + if c.configuration.print_auto: + printable_result.print() - if not isinstance(sigfigs, int): - raise TypeError(f"`sigfigs` must be an int, not {type(sigfigs)}") + # Export automatically + immediate_export_path = c.configuration.export_auto_to + if immediate_export_path != "": + _export(immediate_export_path, print_completed=False) - if sigfigs < 1: - raise ValueError("`sigfigs` must be positive") - - return sigfigs - - -def _parse_decimal_places(decimal_places: Union[int, None]) -> Union[int, None]: - """Parses the number of sigfigs.""" - if decimal_places is None: - return None - - if not isinstance(decimal_places, int): - raise TypeError(f"`decimal_places` must be an int, not {type(decimal_places)}") - - if decimal_places < 0: - raise ValueError("`decimal_places` must be non-negative") - - return decimal_places - - -def _parse_value(value: Union[float, str]) -> _Value: - """Converts the value to a _Value object.""" - if not isinstance(value, (float, str)): - raise TypeError(f"`value` must be a float or string, not {type(value)}") - - if isinstance(value, str): - _check_if_number_string(value) - - return _Value(value) - - -def _parse_uncertainties( - uncertainties: Union[ - float, - str, - Tuple[Union[float, str], str], - List[Union[float, str, Tuple[Union[float, str], str]]], - ] -) -> List[_Uncertainty]: - """Converts the uncertainties to a list of _Uncertainty objects.""" - uncertainties_res = [] - - # no list, but a single value was given - if isinstance(uncertainties, (float, str, Tuple)): - uncertainties = [uncertainties] - - assert isinstance(uncertainties, List) - - for uncert in uncertainties: - if isinstance(uncert, (float, str)): - if isinstance(uncert, str): - _check_if_number_string(uncert) - if float(uncert) <= 0: - raise ValueError("Uncertainty must be positive.") - uncertainties_res.append(_Uncertainty(uncert)) - - elif isinstance(uncert, Tuple): - if not isinstance(uncert[0], (float, str)): - raise TypeError( - f"First argument of uncertainty-tuple must be a float or a string, not {type(uncert[0])}" - ) - if isinstance(uncert[0], str): - _check_if_number_string(uncert[0]) - uncertainties_res.append(_Uncertainty(uncert[0], _parse_name(uncert[1]))) - - else: - raise TypeError(f"Each uncertainty must be a tuple or a float/str, not {type(uncert)}") - - return uncertainties_res + return printable_result diff --git a/src/api/stringifier.py b/src/api/stringifier.py deleted file mode 100644 index 884e0c75..00000000 --- a/src/api/stringifier.py +++ /dev/null @@ -1,100 +0,0 @@ -from domain.result import _Result -from application.helpers import _Helpers - -# Config values: -min_exponent_for_non_scientific_notation = -2 -max_exponent_for_non_scientific_notation = 3 -identifier = "res" - - -class Stringifier: - @classmethod - def result_to_str(cls, result: _Result): - """ - Returns the result as human-readable string. - """ - - value = result.value - uncertainties = result.uncertainties - unit = result.unit - - string = f"{result.name} = " - use_scientific_notation = False - has_unit = unit != "" - - # Determine if scientific notation should be used: - if ( - value.get_exponent() < min_exponent_for_non_scientific_notation - or value.get_exponent() > max_exponent_for_non_scientific_notation - ): - use_scientific_notation = True - - if value.get_min_exponent() > 0: - use_scientific_notation = True - - for u in uncertainties: - if u.uncertainty.get_min_exponent() > 0: - use_scientific_notation = True - - # Create LaTeX string: - if value.get() < 0: - sign = "-" - else: - sign = "" - - if use_scientific_notation: - exponent = value.get_exponent() - factor = 10 ** (-exponent) - - if len(uncertainties) > 0: - string += "(" - - value_normalized = value.get_abs() * factor - decimal_places = value.get_sig_figs()-1 - string += sign - string += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - - for u in uncertainties: - value_normalized = u.uncertainty.get_abs() * factor - decimal_places = exponent-u.uncertainty.get_min_exponent() - string += " ± " - string += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - if len(uncertainties) > 1: - string += rf" ({u.name})" - - if len(uncertainties) > 0: - string += ")" - - string += rf"e{exponent}" - else: - if len(uncertainties) > 0: - string += "(" - - value_normalized = value.get_abs() - decimal_places = value.get_decimal_place() - string += sign - string += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - - for u in uncertainties: - value_normalized = u.uncertainty.get_abs() - decimal_places = u.uncertainty.get_decimal_place() - string += " ± " - string += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - if len(uncertainties) > 1: - string += rf" ({u.name})" - - if len(uncertainties) > 0: - string += ")" - - if has_unit: - unit = ( - unit.replace(r"\per", "/") - .replace(r"\squared", "^2") - .replace(r"\cubed", "^3") - .replace("\\", "") - ) - if unit[0] == "/": - unit = "1" + unit - string += rf" {unit}" - - return string diff --git a/src/api/tables/column.py b/src/api/tables/column.py new file mode 100644 index 00000000..8c92f88a --- /dev/null +++ b/src/api/tables/column.py @@ -0,0 +1,12 @@ +from typing import List, Union + +from domain.tables.column import Column +from domain.result import Result + + +def column( + title: str, + cells: List[Union[Result, str]], + concentrate_units_if_possible: Union[bool, None] = None, +) -> Column: + return Column(title, cells, concentrate_units_if_possible) diff --git a/src/api/tables/table.py b/src/api/tables/table.py new file mode 100644 index 00000000..f89da08e --- /dev/null +++ b/src/api/tables/table.py @@ -0,0 +1,60 @@ +from typing import List, Union + +from application.cache import _res_cache +import api.parsers as parsers +from domain.tables.column import Column +from domain.tables.table import Table + +# "Wrong" import position to avoid circular imports +from api.export import _export # pylint: disable=wrong-import-position,ungrouped-imports +import api.config as c # pylint: disable=wrong-import-position,ungrouped-imports + + +def table( + name: str, + columns: List[Column], + caption: str, + label: Union[str, None] = None, + resize_to_fit_page_: bool = False, + horizontal: bool = False, + concentrate_units_if_possible: bool = True, +): + # Parse user input + name_res = parsers.parse_name(name) + + # Check if columns are valid: + if len(columns) == 0: + raise ValueError("A table must have at least one column.") + + length = None + for column in columns: + if length is None: + length = len(column.cells) + elif length != len(column.cells): + raise ValueError("All columns must have the same number of cells.") + + if length == 0: + raise ValueError("All columns must have at least one cell.") + + # Concentrate units: + for column in columns: + column.concentrate_units(concentrate_units_if_possible) + + # Assemble the table + _table = Table( + name_res, + columns, + caption, + label, + resize_to_fit_page_, + horizontal, + concentrate_units_if_possible, + ) + _res_cache.add_table(name, _table) + + # Export automatically + immediate_export_path = c.configuration.export_auto_to + if immediate_export_path != "": + _export(immediate_export_path, print_completed=False) + + return diff --git a/src/api/tables/table_res.py b/src/api/tables/table_res.py new file mode 100644 index 00000000..770e4270 --- /dev/null +++ b/src/api/tables/table_res.py @@ -0,0 +1,36 @@ +from decimal import Decimal +from typing import Union, List, Tuple + +from domain.result import Result +from api.res import _res + + +def table_res( + value: Union[float, int, str, Decimal], + uncerts: Union[ + float, + int, + str, + Decimal, + Tuple[Union[float, int, str, Decimal], str], + List[Union[float, int, str, Decimal, Tuple[Union[float, int, str, Decimal], str]]], + None, + ] = None, + unit: str = "", + sys: Union[float, int, str, Decimal, None] = None, + stat: Union[float, int, str, Decimal, None] = None, + sigfigs: Union[int, None] = None, + decimal_places: Union[int, None] = None, +) -> Result: + """ + Declares your result. Give it a name and a value. You may also optionally provide + uncertainties (via `uncert` or `sys`/`stat`) and a unit in `siunitx` format. + + You may additionally specify the number of significant figures or decimal places + to round this specific result to, irrespective of your global configuration. + + TODO: provide a link to the docs for more information and examples. + """ + _, result = _res(None, value, uncerts, unit, sys, stat, sigfigs, decimal_places) + + return result diff --git a/src/application/cache.py b/src/application/cache.py index e25f537c..bb796017 100644 --- a/src/application/cache.py +++ b/src/application/cache.py @@ -1,22 +1,42 @@ -from domain.result import _Result +from application.error_messages import RESULT_SHADOWED +from domain.result import Result +from domain.tables.table import Table -class _ResultsCache: +class ResultsCache: """ A cache for all user-defined results. Results are hashed by their name. If the user tries to add a result with a name that already exists in the cache, the new result will replace the old one ("shadowing"). - # TODO: is this the wanted behavior? Maybe print a warning in this case. """ def __init__(self): - self.cache: dict[str, _Result] = {} + self.results: dict[str, Result] = {} + self.tables: dict[str, Table] = {} + self.issue_result_overwrite_warning = True - def add(self, name, result: _Result): - self.cache[name] = result + def configure(self, issue_result_overwrite_warning: bool): + self.issue_result_overwrite_warning = issue_result_overwrite_warning - def get_all_results(self) -> list[_Result]: - return list(self.cache.values()) + def add(self, name, result: Result): + if self.issue_result_overwrite_warning and name in self.results: + print(RESULT_SHADOWED.format(name=name)) -_res_cache = _ResultsCache() + self.results[name] = result + + def add_table(self, name, table: Table): + + if self.issue_result_overwrite_warning and name in self.tables: + print(RESULT_SHADOWED.format(name=name)) + + self.tables[name] = table + + def get_all_results(self) -> list[Result]: + return list(self.results.values()) + + def get_all_tables(self) -> list[Table]: + return list(self.tables.values()) + + +_res_cache = ResultsCache() diff --git a/src/application/error_messages.py b/src/application/error_messages.py new file mode 100644 index 00000000..d4c019f4 --- /dev/null +++ b/src/application/error_messages.py @@ -0,0 +1,67 @@ +# Config and res error messages +SIGFIGS_AND_DECIMAL_PLACES_AT_SAME_TIME = ( + "You can't set both sigfigs and decimal places at the same time. " + "Please choose one or the other." +) +SIGFIGS_FALLBACK_AND_DECIMAL_PLACES_FALLBACK_AT_SAME_TIME = ( + "You can't set both sigfigs_fallback and decimal_places_fallback at the same time. " + "Please choose one or the other." +) +ONE_OF_SIGFIGS_FALLBACK_AND_DECIMAL_PLACES_FALLBACK_MUST_BE_SET = ( + "You need to set either sigfigs_fallback or decimal_places_fallback. Please choose one." +) +CONFIG_SIGFIGS_VALID_RANGE = "sigfigs must be greater than 0 (or -1)." +CONFIG_SIGFIGS_FALLBACK_VALID_RANGE = "sigfigs_fallback must be greater than 0 (or -1)." +SIGFIGS_AND_EXACT_VALUE_AT_SAME_TIME = ( + "You can't set sigfigs and supply an exact value. Please do one or the other." +) +DECIMAL_PLACES_AND_EXACT_VALUE_AT_SAME_TIME = ( + "You can't set decimal places and supply an exact value. Please do one or the other." +) +UNCERT_AND_SYS_STAT_AT_SAME_TIME = ( + "You can't set uncertainties and systematic/statistical uncertainties at the same time. " + "Please provide either the `uncert` param or the `sys`/`stat` params." +) + +# Parser error messages (generic) +STRING_MUST_BE_NUMBER = "String value must be a valid number, not {value}" +FIELD_MUST_BE_STRING = "{field} must be a string, not {type}" +FIELD_MUST_BE_INT = "{field} must be an int, not {type}" +FIELD_MUST_NOT_BE_EMPTY = "{field} must not be empty" +FIELD_MUST_BE_POSITIVE = "{field} must be positive" +FIELD_MUST_BE_NON_NEGATIVE = "{field} must be non-negative" + +# Parser error messages (specific) +STRING_EMPTY_AFTER_IGNORING_INVALID_CHARS = ( + "After ignoring invalid characters, the specified name is empty." +) +VALUE_TYPE = "{field} must be a float, int, Decimal or string, not {type}" +UNCERTAINTIES_MUST_BE_TUPLES_OR = ( + "Each uncertainty must be a tuple or a float/int/Decimal/str, not {type}" +) +UNIT_NOT_PASSED_AS_KEYWORD_ARGUMENT = ( + "Could it be the case you provided a unit but forgot `unit=` in front of it?" +) + +# Helpers +PRECISION_TOO_LOW = ( + "Your precision is set too low to be able to process the given value without any loss of " + "precision. Set a higher precision via: `wiz.config_init (precision=)`." +) +NUMBER_TO_WORD_TOO_HIGH = "For variable names, only use numbers between 0 and 999. Got {number}." + +# Runtime errors +SHORT_RESULT_IS_NONE = "Short result is None, but there should be at least two uncertainties." +INTERNAL_ROUNDER_HIERARCHY_ERROR = "Internal rounder hierarchy error. Please report this bug." +INTERNAL_MIN_EXPONENT_ERROR = "Internal min_exponent not set error. Please report this bug." +ROUND_TO_NEGATIVE_DECIMAL_PLACES = ( + "Internal rounding to negative decimal places. Please report this bug." +) + +# Warnings +INVALID_CHARS_IGNORED = "Invalid characters in name were ignored: {chars}" +NUM_OF_DECIMAL_PLACES_TOO_LOW = ( + "Warning: At least one of the specified values is out of range of the specified " + "number of decimal places. Thus, the exported value will be 0." +) +RESULT_SHADOWED = "Warning: A result with the name '{name}' already exists and will be overwritten." diff --git a/src/application/helpers.py b/src/application/helpers.py index 5e65a1d2..57998e7d 100644 --- a/src/application/helpers.py +++ b/src/application/helpers.py @@ -1,5 +1,8 @@ import math +import decimal +from decimal import Decimal +from application import error_messages _NUMBER_TO_WORD = { 0: "zero", @@ -33,34 +36,48 @@ } -class _Helpers: +class Helpers: @classmethod - def get_exponent(cls, value: float) -> int: + def get_exponent(cls, value: Decimal) -> int: + if value == 0: + return 0 return math.floor(math.log10(abs(value))) @classmethod - def get_first_digit(cls, value: float) -> int: + def get_first_digit(cls, value: Decimal) -> int: n = abs(value) * 10 ** (-cls.get_exponent(value)) return math.floor(n) @classmethod - def round_to_n_decimal_places(cls, value: float, n: int): - return f"{value:.{int(abs(n))}f}" + def round_to_n_decimal_places(cls, value: Decimal, n: int) -> str: + if n < 0: + raise RuntimeError(error_messages.ROUND_TO_NEGATIVE_DECIMAL_PLACES) + + try: + decimal_value = value.quantize(Decimal(f"1.{'0' * n}")) + return f"{decimal_value:.{n}f}" + except decimal.InvalidOperation as exc: + raise ValueError(error_messages.PRECISION_TOO_LOW) from exc @classmethod def number_to_word(cls, number: int) -> str: - if number >= 0 and number <= 19: + if 0 <= number <= 19: return _NUMBER_TO_WORD[number] - elif number >= 0 and number <= 99: + if 0 <= number <= 99: tens = number // 10 * 10 ones = number % 10 if ones == 0: return _NUMBER_TO_WORD[tens] - else: - return ( - _NUMBER_TO_WORD[tens] - + _NUMBER_TO_WORD[ones][0].upper() - + _NUMBER_TO_WORD[ones][1:] - ) - else: - raise RuntimeError("Runtime error.") + return _NUMBER_TO_WORD[tens] + cls.capitalize(_NUMBER_TO_WORD[ones]) + if 0 <= number <= 999: + hundreds = number // 100 + tens = number % 100 + if tens == 0: + return _NUMBER_TO_WORD[hundreds] + "Hundred" + return _NUMBER_TO_WORD[hundreds] + "Hundred" + cls.capitalize(cls.number_to_word(tens)) + + raise ValueError(error_messages.NUMBER_TO_WORD_TOO_HIGH.format(number=number)) + + @classmethod + def capitalize(cls, s: str) -> str: + return s[0].upper() + s[1:] diff --git a/src/application/latex_better_siunitx_stringifier.py b/src/application/latex_better_siunitx_stringifier.py new file mode 100644 index 00000000..46ee91b9 --- /dev/null +++ b/src/application/latex_better_siunitx_stringifier.py @@ -0,0 +1,59 @@ +from typing import List + +from application.helpers import Helpers +from application.stringifier import Stringifier + + +class LatexBetterSiunitxStringifier(Stringifier): + """ + Provides methods to convert results to LaTeX commands. + + We assume the result to already be correctly rounded at this point. + """ + + # pylint: disable=duplicate-code + plus_minus = r"\pm" + negative_sign = "-" + positive_sign = "" + + left_parenthesis = r"\left(" + right_parenthesis = r"\right)" + + value_prefix = "" + value_suffix = "" + + uncertainty_name_prefix = r"\Uncert" + uncertainty_name_suffix = "" + + scientific_notation_prefix = "e" + scientific_notation_suffix = "" + + unit_prefix = "" + unit_suffix = "" + # pylint: enable=duplicate-code + + def _modify_uncertainty_name(self, name) -> str: + return Helpers.capitalize(name) + + # pylint: disable-next=too-many-arguments + def _assemble_str_parts( + self, + sign: str, + value_rounded: str, + uncertainties_rounded: List[str], + should_use_parentheses: bool, + use_scientific_notation: bool, + exponent: int, + unit: str, + ): + num_part = f"{sign}{value_rounded}{''.join(uncertainties_rounded)}" + + if use_scientific_notation: + num_part += f" e{str(exponent)}" + + if unit != "": + string = rf"\qty{{{num_part}}}{{{unit}}}" + else: + string = rf"\num{{{num_part}}}" + + return string diff --git a/src/application/latex_commandifier.py b/src/application/latex_commandifier.py new file mode 100644 index 00000000..8012f41a --- /dev/null +++ b/src/application/latex_commandifier.py @@ -0,0 +1,95 @@ +from application.stringifier import Stringifier +from application.helpers import Helpers +from application.latex_ifelse import LatexIfElseBuilder +from application import error_messages +from domain.result import Result + + +class LatexCommandifier: + """Makes use of a LaTeX stringifier to embed a result (e.g. \\qty{1.23}{\\m}) + into a LaTeX command (e.g. \\newcommand{\\resultImportant}{\\qty{1.23}{\\m}}). + """ + + def __init__(self, stringifier: Stringifier): + self.s = stringifier + + def result_to_latex_cmd(self, result: Result) -> str: + """ + Returns the result as LaTeX command to be used in a .tex file. + """ + builder = LatexIfElseBuilder() + + cmd_name = f"{self.s.config.identifier}{Helpers.capitalize(result.name)}" + latex_str = rf"\newcommand*{{\{cmd_name}}}[1][]{{" + "\n" + + # Default case (full result) & value + builder.add_branch("", self.result_to_latex_str(result)) + builder.add_branch("value", self.result_to_latex_str_value(result)) + + # Without uncertainty + if len(result.uncertainties) > 0: + builder.add_branch("withoutUncert", self.result_to_latex_str_without_uncert(result)) + + # Single uncertainties + for i, u in enumerate(result.uncertainties): + if len(result.uncertainties) == 1: + uncertainty_name = "uncert" + else: + uncertainty_name = u.name if u.name != "" else Helpers.number_to_word(i + 1) + uncertainty_name = f"uncert{Helpers.capitalize(uncertainty_name)}" + uncertainty_latex_str = self.s.create_str(u.uncertainty, [], result.unit) + builder.add_branch(uncertainty_name, uncertainty_latex_str) + + # Total uncertainty and short result + if len(result.uncertainties) >= 2: + short_result = result.get_short_result() + if short_result is None: + raise RuntimeError(error_messages.SHORT_RESULT_IS_NONE) + uncertainty_latex_str = self.s.create_str( + short_result.uncertainties[0].uncertainty, [], result.unit + ) + builder.add_branch("uncertTotal", uncertainty_latex_str) + builder.add_branch("short", self.result_to_latex_str(short_result)) + + # Unit + if result.unit != "": + builder.add_branch("unit", rf"\unit{{{result.unit}}}") + builder.add_branch("withoutUnit", self.result_to_latex_str_without_unit(result)) + + # Error message + keywords = builder.keywords + if len(keywords) > 0: + error_message = "Use one of these keywords (or no keyword at all): " + error_message += ", ".join([rf"\texttt{{{k}}}" for k in keywords]) + else: + error_message = "This variable can only be used without keywords." + builder.add_else(rf"\scriptsize{{\textbf{{{error_message}}}}}") + + latex_str += builder.build() + latex_str += "\n}" + + return latex_str + + def result_to_latex_str(self, result: Result) -> str: + """ + Returns the result as LaTeX string making use of the siunitx package. + """ + return self.s.create_str(result.value, result.uncertainties, result.unit) + + def result_to_latex_str_value(self, result: Result) -> str: + """ + Returns only the value as LaTeX string making use of the siunitx package. + """ + return self.s.create_str(result.value, [], "") + + def result_to_latex_str_without_uncert(self, result: Result) -> str: + """ + Returns the result without uncertainty as LaTeX string making use of the siunitx package. + """ + return self.s.create_str(result.value, [], result.unit) + + def result_to_latex_str_without_unit(self, result: Result) -> str: + """ + Returns the result without unit as LaTeX string making use of the siunitx package. + """ + return self.s.create_str(result.value, result.uncertainties, "") diff --git a/src/application/latex_ifelse.py b/src/application/latex_ifelse.py new file mode 100644 index 00000000..3dc85289 --- /dev/null +++ b/src/application/latex_ifelse.py @@ -0,0 +1,33 @@ +class LatexIfElseBuilder: + def __init__(self): + self.latex: str = "" + self._num_parentheses_to_close: int = 0 + self.keywords: list[str] = [] + + def add_branch(self, keyword: str, body: str): + # Condition + if self.latex == "": + self.latex += rf" \ifthenelse{{\equal{{#1}}{{{keyword}}}}}{{" + else: + self.latex += "\n" + self.latex += rf" }}{{\ifthenelse{{\equal{{#1}}{{{keyword}}}}}{{" + self._num_parentheses_to_close += 1 + + if keyword != "": + self.keywords.append(keyword) + + # Body + self.latex += "\n" + self.latex += rf" {body}" + + def add_else(self, body: str): + self.latex += "\n" + self.latex += r" }{" + self.latex += rf"{body}" + self._num_parentheses_to_close += 1 + + def build(self) -> str: + for _ in range(self._num_parentheses_to_close): + self.latex += "}" + + return self.latex diff --git a/src/application/latex_stringifier.py b/src/application/latex_stringifier.py new file mode 100644 index 00000000..08ae6f1b --- /dev/null +++ b/src/application/latex_stringifier.py @@ -0,0 +1,30 @@ +from application.stringifier import Stringifier + + +class LatexStringifier(Stringifier): + """ + Provides methods to convert results to LaTeX commands. + + We assume the result to already be correctly rounded at this point. + """ + + # pylint: disable=duplicate-code + plus_minus = r"\pm" + negative_sign = "-" + positive_sign = "" + + left_parenthesis = r"\left(" + right_parenthesis = r"\right)" + + value_prefix = r"\num{" + value_suffix = r"}" + + uncertainty_name_prefix = r"_{\text{" + uncertainty_name_suffix = r"}}" + + scientific_notation_prefix = r" \cdot 10^{" + scientific_notation_suffix = "}" + + unit_prefix = r" \, \unit{" + unit_suffix = "}" + # pylint: enable=duplicate-code diff --git a/src/application/latexer.py b/src/application/latexer.py deleted file mode 100644 index 5c19fb13..00000000 --- a/src/application/latexer.py +++ /dev/null @@ -1,211 +0,0 @@ -from typing import List - -import textwrap -from domain.result import _Result -from domain.value import _Value -from domain.uncertainty import _Uncertainty -from application.helpers import _Helpers -from application.rounder import _Rounder - - -# Config values: -min_exponent_for_non_scientific_notation = -2 -max_exponent_for_non_scientific_notation = 3 -identifier = "res" - - -class _LaTeXer: - @classmethod - def result_to_latex_cmd(cls, result: _Result) -> str: - """ - Returns the result as LaTeX command to be used in a .tex file. - """ - - cmd_name = identifier + result.name[0].upper() + result.name[1:] - - latex_str = rf""" - \newcommand*{{\{cmd_name}}}[1][]{{ - \ifthenelse{{\equal{{#1}}{{}}}}{{ - {cls.result_to_latex_str(result)} - """ - latex_str = textwrap.dedent(latex_str).strip() - - number_of_parentheses_to_close = 0 - keywords = [] - - # Value only: - if len(result.uncertainties) > 0: - latex_str += "\n" - latex_str += r" }{\ifthenelse{\equal{#1}{valueOnly}}{" - latex_str += "\n" - latex_str += rf" {cls.result_to_latex_str_value_only(result)}" - keywords.append("valueOnly") - - number_of_parentheses_to_close += 1 - - # Single uncertainties: - for i, u in enumerate(result.uncertainties): - if len(result.uncertainties) == 1: - uncertainty_name = "error" - else: - if u.name != "": - uncertainty_name = u.name - else: - uncertainty_name = _Helpers.number_to_word(i + 1) - uncertainty_name = "error" + uncertainty_name[0].upper() + uncertainty_name[1:] - error_latex_str = cls._create_latex_str(u.uncertainty, [], result.unit) - - latex_str += "\n" - latex_str += rf" }}{{\ifthenelse{{\equal{{#1}}{{{uncertainty_name}}}}}{{" - latex_str += "\n" - latex_str += rf" {error_latex_str}" - keywords.append(uncertainty_name) - - number_of_parentheses_to_close += 1 - - # Total uncertainty and short result: - if len(result.uncertainties) > 1: - short_result = result.get_short_result() - _Rounder.round_result(short_result) - - error_latex_str = cls._create_latex_str( - short_result.uncertainties[0].uncertainty, [], result.unit - ) - - latex_str += "\n" - latex_str += r" }{\ifthenelse{\equal{#1}{errorTotal}}{" - latex_str += "\n" - latex_str += rf" {error_latex_str}" - keywords.append("errorTotal") - - number_of_parentheses_to_close += 1 - - latex_str += "\n" - latex_str += r" }{\ifthenelse{\equal{#1}{short}}{" - latex_str += "\n" - latex_str += rf" {cls.result_to_latex_str(short_result)}" - keywords.append("short") - - number_of_parentheses_to_close += 1 - - # Unit: - if result.unit != "": - latex_str += "\n" - latex_str += r" }{\ifthenelse{\equal{#1}{unit}}{" - latex_str += "\n" - latex_str += rf" \unit{{{result.unit}}}" - keywords.append("unit") - - number_of_parentheses_to_close += 1 - - # Error message: - if len(keywords) > 0: - latex_str += ( - "\n" - + r" }{\tiny\textbf{Please specify one of the following keywords: " - + ", ".join([rf"\texttt{{{k}}}" for k in keywords]) - + r" or don't use any keyword at all.}\normalsize}" - ) - else: - latex_str += ( - "\n" - + r" }{\tiny\textbf{This variable can only be used without keyword.}\normalsize}" - ) - - for _ in range(number_of_parentheses_to_close): - latex_str += "}" - latex_str += "\n}" - - return latex_str - - @classmethod - def result_to_latex_str(cls, result: _Result) -> str: - """ - Returns the result as LaTeX string making use of the siunitx package. - """ - return cls._create_latex_str(result.value, result.uncertainties, result.unit) - - @classmethod - def result_to_latex_str_value_only(cls, result: _Result) -> str: - """ - Returns only the value as LaTeX string making use of the siunitx package. - """ - return cls._create_latex_str(result.value, [], result.unit) - - @classmethod - def _create_latex_str(cls, value: _Value, uncertainties: List[_Uncertainty], unit: str) -> str: - """ - Returns the result as LaTeX string making use of the siunitx package. - - This string does not yet contain "\newcommand*{}". - """ - - latex_str = "" - use_scientific_notation = False - has_unit = unit != "" - - # Determine if scientific notation should be used: - if ( - value.get_exponent() < min_exponent_for_non_scientific_notation - or value.get_exponent() > max_exponent_for_non_scientific_notation - ): - use_scientific_notation = True - - if value.get_min_exponent() > 0: - use_scientific_notation = True - - for u in uncertainties: - if u.uncertainty.get_min_exponent() > 0: - use_scientific_notation = True - - # Create LaTeX string: - if value.get() < 0: - sign = "-" - else: - sign = "" - - if use_scientific_notation: - exponent = value.get_exponent() - factor = 10 ** (-exponent) - - if len(uncertainties) > 0: - latex_str += "(" - - value_normalized = value.get_abs() * factor - decimal_places = value.get_sig_figs() - 1 - latex_str += sign - latex_str += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - - for u in uncertainties: - value_normalized = u.uncertainty.get_abs() * factor - decimal_places = exponent - u.uncertainty.get_min_exponent() - latex_str += r" \pm " - latex_str += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - if len(uncertainties) > 1: - latex_str += rf"_{{\text{{{u.name}}}}}" - - if len(uncertainties) > 0: - latex_str += ")" - - latex_str += rf" \cdot 10^{{{exponent}}}" - else: - if len(uncertainties) > 0: - latex_str += "(" - - value_normalized = value.get_abs() - decimal_places = value.get_decimal_place() - latex_str += sign - latex_str += _Helpers.round_to_n_decimal_places(value_normalized, decimal_places) - - for u in uncertainties: - latex_str += rf" \pm {_Helpers.round_to_n_decimal_places(u.uncertainty.get_abs(), u.uncertainty.get_decimal_place())}" - if len(uncertainties) > 1: - latex_str += rf"_{{\text{{{u.name}}}}}" - - if len(uncertainties) > 0: - latex_str += ")" - - if has_unit: - latex_str += rf"\ \unit{{{unit}}}" - - return latex_str diff --git a/src/application/rounder.py b/src/application/rounder.py index b9a63a5b..1eef8d2c 100644 --- a/src/application/rounder.py +++ b/src/application/rounder.py @@ -1,86 +1,176 @@ from typing import List +from decimal import Decimal -from domain.result import _Result -from domain.uncertainty import _Uncertainty -from application.helpers import _Helpers +from dataclasses import dataclass +from domain.result import Result +from domain.uncertainty import Uncertainty +from domain.value import Value, DecimalPlacesError +from application.helpers import Helpers +from application import error_messages -# Config values: -standard_sigfigs = 2 +@dataclass +class RoundingConfig: + sigfigs: int + decimal_places: int + sigfigs_fallback: int + decimal_places_fallback: int -class _Rounder: +class Rounder: + @classmethod - def round_result(cls, result: _Result) -> None: + def round_result(cls, result: Result, config: RoundingConfig) -> None: """ In-place rounds all numerical fields of a result to the correct number of significant figures. - Rounding hierarchy for uncertainties: + # Rounding hierarchy: + + 1. Is uncertainty exact? Do not round. + 2. Round uncertainty according to the hierarchy below. + + + # Rounding hierarchy for inexact uncertainty: + + 1. Is result value exact? + Round uncertainty according to result value. + + 2. Is number of sigfigs of result given? + Round value according to number of sigfigs. + Round uncertainties according to value. + + 3. Is number of decimal places of result given? + Round value according to number of decimal places. + Round uncertainties according to value. - 1. Is uncertainty exact? Do not round! - 2. Round uncertainty according to the hierarchy below! + 4. Is default for sigfigs given (not -1) (see config)? + Round value according to number of sigfigs. + Round uncertainties according to value. - Rounding hierarchy: + 5. Is default for decimal places given (not -1) (see config)? + Round value according to number of decimal places. + Round uncertainties according to value. - 1. Is value exact? Do not round! Round uncertainty according to value! - 2. Is number of sigfigs given? Round value according to number of sigfigs! Round - uncertainties according to value. - 3. Is number of decimal places given? Round value according to number of decimal places! - Round uncertainties according to value. - 4. Is at least one uncertainty given? Round each uncertainty according to standard rules! - Round value according to uncertainty with lowest min exponent! - 5. Round value to 2 sigfigs. + 6. Is at least one uncertainty given? + Round each uncertainty according to standard rules. + Round value according to uncertainty with lowest min exponent. - TODO: Warning message if user specifies exact value and sigfigs etc. + 7. Is fallback for sigfigs given (not -1) (see config)? + Round value according to number of sigfigs. + + 8. Is fallback for decimal places given (not -1) (see config)? + Round value according to number of decimal places. """ + + print_decimal_places_error = cls._round_result(result, config) + + short = result.get_short_result() + if short: + print_decimal_places_error = ( + cls._round_result(short, config) or print_decimal_places_error + ) + + if print_decimal_places_error: + print(error_messages.NUM_OF_DECIMAL_PLACES_TOO_LOW) + + @classmethod + # pylint: disable-next=too-many-branches + def _round_result(cls, result: Result, config: RoundingConfig) -> bool: + """See the docstring of the public `round_result` for details.""" + value = result.value uncertainties = result.uncertainties + print_decimal_places_error = False + # Rounding hierarchy 1: if value.is_exact(): - cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent()) + print_decimal_places_error = cls._uncertainties_set_min_exponents( + uncertainties, value.get_min_exponent() + ) # Rounding hierarchy 2: elif result.sigfigs is not None: value.set_sigfigs(result.sigfigs) - cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent()) + print_decimal_places_error = cls._uncertainties_set_min_exponents( + uncertainties, value.get_min_exponent() + ) # Rounding hierarchy 3: elif result.decimal_places is not None: - value.set_min_exponent(-result.decimal_places) - cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent()) + print_decimal_places_error = cls._value_set_min_exponent(value, -result.decimal_places) + print_decimal_places_error = cls._uncertainties_set_min_exponents( + uncertainties, value.get_min_exponent() + ) # Rounding hierarchy 4: + elif config.sigfigs > -1: + value.set_sigfigs(config.sigfigs) + print_decimal_places_error = cls._uncertainties_set_min_exponents( + uncertainties, value.get_min_exponent() + ) + + # Rounding hierarchy 5: + elif config.decimal_places > -1: + min_exponent = -config.decimal_places + print_decimal_places_error = cls._value_set_min_exponent(value, min_exponent) + print_decimal_places_error = cls._uncertainties_set_min_exponents( + uncertainties, min_exponent + ) + + # Rounding hierarchy 6: elif len(uncertainties) > 0: for u in uncertainties: if u.uncertainty.is_exact(): continue - normalized_value = abs(u.uncertainty.get()) * 10 ** ( - -_Helpers.get_exponent(u.uncertainty.get()) - ) - - print(normalized_value) + shift = Decimal(f"1e{-Helpers.get_exponent(u.uncertainty.get())}") + normalized_value = abs(u.uncertainty.get()) * shift if round(normalized_value, 1) >= 3.0: u.uncertainty.set_sigfigs(1) else: u.uncertainty.set_sigfigs(2) - min_exponent = min([u.uncertainty.get_min_exponent() for u in uncertainties]) - value.set_min_exponent(min_exponent) + min_exponent = min(u.uncertainty.get_min_exponent() for u in uncertainties) + print_decimal_places_error = cls._value_set_min_exponent(value, min_exponent) + + # Rounding hierarchy 7: + elif config.sigfigs_fallback > -1: + value.set_sigfigs(config.sigfigs_fallback) + + # Rounding hierarchy 8: + elif config.decimal_places_fallback > -1: + min_exponent = -config.decimal_places_fallback + print_decimal_places_error = cls._value_set_min_exponent(value, min_exponent) - # Rounding hierarchy 5: else: - value.set_sigfigs(standard_sigfigs) - cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent()) + # This branch cannot be reached, because the config makes sure that + # either`sigfigs_fallback` or `decimal_places_fallback` is set. + raise RuntimeError(error_messages.INTERNAL_ROUNDER_HIERARCHY_ERROR) + + return print_decimal_places_error + + @classmethod + def _value_set_min_exponent(cls, value: Value, min_exponent: int) -> bool: + try: + value.set_min_exponent(min_exponent) + return False + except DecimalPlacesError as _: + return True @classmethod def _uncertainties_set_min_exponents( - cls, uncertainties: List[_Uncertainty], min_exponent: int - ) -> None: + cls, uncertainties: List[Uncertainty], min_exponent: int + ) -> bool: + print_decimal_places_error = False + for u in uncertainties: if not u.uncertainty.is_exact(): - u.uncertainty.set_min_exponent(min_exponent) - return + try: + u.uncertainty.set_min_exponent(min_exponent) + except DecimalPlacesError as _: + print_decimal_places_error = True + + return print_decimal_places_error diff --git a/src/application/stringifier.py b/src/application/stringifier.py new file mode 100644 index 00000000..db6da6dc --- /dev/null +++ b/src/application/stringifier.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass +from typing import List, Tuple +from typing import Protocol, ClassVar +from decimal import Decimal + +# for why we use a Protocol instead of a ABC class, see +# https://github.com/microsoft/pyright/issues/2601#issuecomment-977053380 + +from domain.value import Value +from domain.uncertainty import Uncertainty +from application.helpers import Helpers + + +@dataclass +class StringifierConfig: + min_exponent_for_non_scientific_notation: int + max_exponent_for_non_scientific_notation: int + identifier: str + table_identifier: str + + +class Stringifier(Protocol): + """ + Provides methods to convert results to strings of customizable pattern. + + We assume the result to already be correctly rounded at this point. + """ + + config: StringifierConfig + + # pylint: disable=duplicate-code + plus_minus: ClassVar[str] + negative_sign: ClassVar[str] + positive_sign: ClassVar[str] + + left_parenthesis: ClassVar[str] + right_parenthesis: ClassVar[str] + + value_prefix: ClassVar[str] + value_suffix: ClassVar[str] + + uncertainty_name_prefix: ClassVar[str] + uncertainty_name_suffix: ClassVar[str] + + scientific_notation_prefix: ClassVar[str] + scientific_notation_suffix: ClassVar[str] + + unit_prefix: ClassVar[str] + unit_suffix: ClassVar[str] + # pylint: enable=duplicate-code + + def __init__(self, config: StringifierConfig): + self.config = config + + def create_str(self, value: Value, uncertainties: List[Uncertainty], unit: str) -> str: + """ + Returns the result as LaTeX string making use of the siunitx package. + + This string does not yet contain "\newcommand*{}". + """ + use_scientific_notation = self._should_use_scientific_notation(value, uncertainties) + should_use_parentheses = len(uncertainties) > 0 and (use_scientific_notation or unit != "") + + sign = self._value_to_sign_str(value) + value_rounded, exponent, factor = self._value_to_str(value, use_scientific_notation) + + uncertainties_rounded = [] + for u in uncertainties: + u_rounded = self._uncertainty_to_str(u, use_scientific_notation, exponent, factor) + u_rounded = f" {self.plus_minus} {self.value_prefix}{u_rounded}{self.value_suffix}" + if u.name != "": + u_rounded += self.uncertainty_name_prefix + u_rounded += self._modify_uncertainty_name(u.name) + u_rounded += self.uncertainty_name_suffix + uncertainties_rounded.append(u_rounded) + + # Assemble everything together + return self._assemble_str_parts( + sign, + value_rounded, + uncertainties_rounded, + should_use_parentheses, + use_scientific_notation, + exponent, + unit, + ) + + # pylint: disable-next=too-many-arguments + def _assemble_str_parts( + self, + sign: str, + value_rounded: str, + uncertainties_rounded: List[str], + should_use_parentheses: bool, + use_scientific_notation: bool, + exponent: int, + unit: str, + ): + string = f"{sign}{value_rounded}{''.join(uncertainties_rounded)}" + + if should_use_parentheses: + string = f"{self.left_parenthesis}{string}{self.right_parenthesis}" + + if use_scientific_notation: + e = f"{self.scientific_notation_prefix}{str(exponent)}{self.scientific_notation_suffix}" + string += e + + if unit != "": + string += f"{self.unit_prefix}{self._modify_unit(unit)}{self.unit_suffix}" + + return string + + def _value_to_sign_str(self, value: Value) -> str: + return self.negative_sign if value.get() < 0 else self.positive_sign + + def _value_to_str( + self, value: Value, use_scientific_notation: bool + ) -> Tuple[str, int, Decimal]: + exponent = value.get_exponent() + factor = Decimal(f"1e{-exponent}") if use_scientific_notation else Decimal("1.0") + + value_normalized = value.get_abs() * factor + decimal_places = ( + value.get_sig_figs() - 1 if use_scientific_notation else value.get_decimal_place() + ) + + return Helpers.round_to_n_decimal_places(value_normalized, decimal_places), exponent, factor + + def _uncertainty_to_str( + self, u: Uncertainty, use_scientific_notation: bool, exponent: int, factor: Decimal + ) -> str: + uncertainty_normalized = u.uncertainty.get_abs() * factor + decimal_places = ( + exponent - u.uncertainty.get_min_exponent() + if use_scientific_notation + else u.uncertainty.get_decimal_place() + ) + return Helpers.round_to_n_decimal_places(uncertainty_normalized, decimal_places) + + def _should_use_scientific_notation( + self, value: Value, uncertainties: List[Uncertainty] + ) -> bool: + """ + Returns whether scientific notation should be used for the given value and uncertainties. + """ + exponent = value.get_exponent() + if ( + exponent < self.config.min_exponent_for_non_scientific_notation + or exponent > self.config.max_exponent_for_non_scientific_notation + ): + return True + + if value.get_min_exponent() > 0: + return True + + for u in uncertainties: + if u.uncertainty.get_min_exponent() > 0: + return True + + return False + + def _modify_unit(self, unit: str) -> str: + """ + Returns the modified unit. + """ + return unit + + def _modify_uncertainty_name(self, name: str) -> str: + """ + Returns the modified value (as string). + """ + return name diff --git a/src/application/tables/table_latex_commandifier.py b/src/application/tables/table_latex_commandifier.py new file mode 100644 index 00000000..17a029c8 --- /dev/null +++ b/src/application/tables/table_latex_commandifier.py @@ -0,0 +1,142 @@ +from application.stringifier import Stringifier +from application.helpers import Helpers +from domain.result import Result +from domain.tables.table import Table + + +class TableLatexCommandifier: + """Makes use of a LaTeX stringifier to embed a table into a LaTeX command.""" + + def __init__(self, stringifier: Stringifier): + self.s = stringifier + + def table_to_latex_cmd(self, table: Table) -> str: + """ + Returns the table as LaTeX command to be used in a .tex file. + """ + + cmd_name = f"{self.s.config.table_identifier}{Helpers.capitalize(table.name)}" + + # New command: + latex_str = rf"\newcommand*{{\{cmd_name}}}[1][]{{" + "\n" + + # Table header: + latex_str += r"\begin{table}[#1]" + "\n" + latex_str += r"\begin{center}" + "\n" + if table.resize_to_fit_page: + latex_str += r"\resizebox{\textwidth}{!}{" + + if table.horizontal: + latex_str += self._table_to_latex_tabular_horizontal(table) + else: + latex_str += self._table_to_latex_tabular_vertical(table) + + # Table footer: + if table.resize_to_fit_page: + latex_str += "}" + if table.caption != "": + latex_str += rf"\caption{{{table.caption}}}" + "\n" + latex_str += rf"\label{{{table.label if table.label is not None else cmd_name}}}" + "\n" + latex_str += "\\end{center}\n" + latex_str += "\\end{table}\n" + latex_str += "}" + + return latex_str + + def _table_to_latex_tabular_vertical(self, table: Table) -> str: + latex_str = r"\begin{tabular}{|" + for _ in range(len(table.columns)): + latex_str += r"c|" + latex_str += "}\n" + latex_str += r"\hline" + "\n" + + # Header row: + is_first_column = True + for column in table.columns: + if not is_first_column: + latex_str += "&" + is_first_column = False + + latex_str += rf"\textbf{{{column.title}}}" + + # Unit row: + if self._exist_units(table): + latex_str += "\\\\\n" + is_first_column = True + for column in table.columns: + if not is_first_column: + latex_str += "&" + is_first_column = False + + if column.unit != "": + latex_str += rf"$[\unit{{{column.unit}}}]$" + latex_str += "\\\\ \\hline \n" + + # Value rows: + for i, _ in enumerate(table.columns[0].cells): + is_first_column = True + for column in table.columns: + if not is_first_column: + latex_str += "&" + is_first_column = False + + cell = column.cells[i] + + if isinstance(cell, Result): + value_str = self.s.create_str( + cell.value, cell.uncertainties, cell.unit if column.unit == "" else "" + ) + latex_str += f"${value_str}$" + else: + latex_str += str(cell) + + latex_str += "\\\\\n" + + latex_str += "\\hline\n" + latex_str += "\\end{tabular}\n" + + return latex_str + + def _table_to_latex_tabular_horizontal(self, table: Table) -> str: + latex_str = r"\begin{tabular}{|l" + if self._exist_units(table): + latex_str += r"c||" + else: + latex_str += r"||" + for _ in range(len(table.columns[0].cells)): + latex_str += r"c|" + latex_str += "}\n" + latex_str += r"\hline" + "\n" + + # Iterate through columns (that are rows now): + for row in table.columns: + # Header column: + latex_str += rf"\textbf{{{row.title}}}" + + # Unit column: + if self._exist_units(table): + if row.unit != "": + latex_str += rf" & $[\unit{{{row.unit}}}]$" + else: + latex_str += " & " + + # Value columns: + for cell in row.cells: + if isinstance(cell, Result): + value_str = self.s.create_str( + cell.value, cell.uncertainties, cell.unit if row.unit == "" else "" + ) + latex_str += f" & ${value_str}$" + else: + latex_str += f" & {cell}" + latex_str += "\\\\ \\hline \n" + + latex_str += "\\end{tabular}\n" + + return latex_str + + def _exist_units(self, table: Table) -> bool: + for column in table.columns: + if column.unit != "": + return True + return False diff --git a/src/domain/result.py b/src/domain/result.py index d34b5a65..1fce9e50 100644 --- a/src/domain/result.py +++ b/src/domain/result.py @@ -1,36 +1,49 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Union +from copy import copy +from decimal import Decimal -from domain.uncertainty import _Uncertainty -from domain.value import _Value +from domain.uncertainty import Uncertainty +from domain.value import Value @dataclass -class _Result: +class Result: """ A general-purpose result, i.e. a value that was somehow measured or calculated, along with a unit and optional uncertainties (list might be empty). """ name: str - value: _Value + value: Value unit: str - uncertainties: list[_Uncertainty] + uncertainties: list[Uncertainty] sigfigs: Union[int, None] decimal_places: Union[int, None] - def get_total_uncertainty(self) -> _Uncertainty: - s = 0 + total_uncertainty: Union[Uncertainty, None] = field(init=False) + + def __post_init__(self): + if len(self.uncertainties) >= 2: + self.total_uncertainty = self._calculate_total_uncertainty() + else: + self.total_uncertainty = None + + def _calculate_total_uncertainty(self) -> Uncertainty: + total = Decimal("0") for u in self.uncertainties: - s += u.uncertainty.get() ** 2 - return _Uncertainty(s**0.5) + total += u.uncertainty.get() ** 2 + return Uncertainty(Value(total.sqrt())) + + def get_short_result(self) -> Union["Result", None]: + if self.total_uncertainty is None: + return None - def get_short_result(self) -> "_Result": - return _Result( + return Result( self.name, - self.value, + copy(self.value), self.unit, - [self.get_total_uncertainty()], + [copy(self.total_uncertainty)], self.sigfigs, self.decimal_places, ) diff --git a/src/domain/tables/column.py b/src/domain/tables/column.py new file mode 100644 index 00000000..7a8f9d12 --- /dev/null +++ b/src/domain/tables/column.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Union, List + +from domain.result import Result + + +@dataclass +class Column: + """ + A table column. + """ + + title: str + cells: List[Union[Result, str]] + unit: str + concentrate_units_if_possible: Union[bool, None] + + def __init__( + self, + title: str, + cells: List[Union[Result, str]], + concentrate_units_if_possible: Union[bool, None] = None, + ): + """ + Init method. + + The parameter `concentrate_units_if_possible` is `None` by default and only overwrites the + master setting from the table object if it is manually set to `True` or `False`. + """ + self.title = title + self.cells = cells + self.unit = "" + self.concentrate_units_if_possible = concentrate_units_if_possible + + def concentrate_units(self, concentrate_units_if_possible_master: bool): + """ + Concentrates the units of the cells in this column if possible and if desired. + """ + + # Check if concentration of units is desired: + if self.concentrate_units_if_possible is not None: + should_concentrate_units = self.concentrate_units_if_possible + else: + should_concentrate_units = concentrate_units_if_possible_master + + # Check if concentration of units is possible given the cell values: + unit = None + for cell in self.cells: + if isinstance(cell, Result): + if unit is None: + unit = cell.unit + elif unit != cell.unit: + should_concentrate_units = False + break + else: + should_concentrate_units = False + break + + if should_concentrate_units and unit is not None: + self.unit = unit diff --git a/src/domain/tables/table.py b/src/domain/tables/table.py new file mode 100644 index 00000000..9f7d09b2 --- /dev/null +++ b/src/domain/tables/table.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import List, Union + +from domain.tables.column import Column + + +@dataclass +class Table: + """ + A table. + """ + + name: str + columns: List[Column] + caption: str + label: Union[str, None] + resize_to_fit_page: bool + horizontal: bool + concentrate_units_if_possible: bool diff --git a/src/domain/uncertainty.py b/src/domain/uncertainty.py index 85ccb8ed..57bcbf7b 100644 --- a/src/domain/uncertainty.py +++ b/src/domain/uncertainty.py @@ -1,9 +1,7 @@ -from typing import Union +from domain.value import Value -from domain.value import _Value - -class _Uncertainty: +class Uncertainty: """ A named uncertainty value, e.g. a systematic uncertainty of ±0.1cm when measuring a length. In this case the uncertainty would be 0.1 and the name @@ -15,9 +13,6 @@ class _Uncertainty: interchangeably. """ - def __init__(self, uncertainty: Union[float, str], name: str = ""): - self.uncertainty = _Value(uncertainty) + def __init__(self, uncertainty: Value, name: str = ""): + self.uncertainty = uncertainty self.name = name - - def value(self) -> _Value: - return self.uncertainty diff --git a/src/domain/value.py b/src/domain/value.py index 6c8fa12c..1a454d18 100644 --- a/src/domain/value.py +++ b/src/domain/value.py @@ -1,52 +1,50 @@ from typing import Union +from decimal import Decimal -from application.helpers import _Helpers +from application.helpers import Helpers +from application import error_messages -class _Value: +class DecimalPlacesError(Exception): + pass + + +class Value: """ - A floating-point value represented as string that is either treated as exact - (does not have any uncertainties) or as inexact (has uncertainties). - Values that are exact will be exempt from significant figures rounding. + A decimal value. - Note that is_exact signifies if the value is to be taken as a *literal* value, - i.e. "3.14000" will be output as "3.14000" and not "3.14" if is_exact is True. - TODO: maybe find a better word for "exact"? + It is either exact or inexact. Values that are set as exact + will be exempt form any rounding. If the value is set as exact, it will be + treated as a *literal* value, i.e. "3.14000" will be output as "3.14000" + and not "3.14". """ - _value: float + _value: Decimal _is_exact: bool _max_exponent: int _min_exponent: int - # "3400.0" -> 3400, -1, 3 - # "3400" -> 3400, 0, 3 - # "3.4e3" -> 3400, 2, 3 + def __init__(self, value: Decimal, min_exponent: Union[int, None] = None): + self._value = value - def __init__(self, value: Union[float, str]): - if isinstance(value, str): - self._value = float(value) + if min_exponent is not None: + self._min_exponent = min_exponent self._is_exact = True - - # Determine min exponent: - value_str = value - exponent_offset = 0 - if "e" in value_str: - exponent_offset = int(value_str[value_str.index("e") + 1 :]) - value_str = value_str[0 : value_str.index("e")] - if "." in value_str: - decimal_places = len(value_str) - value_str.index(".") - 1 - self._min_exponent = -decimal_places + exponent_offset - else: - self._min_exponent = exponent_offset else: - self._value = value self._is_exact = False - self._max_exponent = _Helpers.get_exponent(self._value) + self._max_exponent = Helpers.get_exponent(self._value) def set_min_exponent(self, min_exponent: int): self._min_exponent = min_exponent + if min_exponent > self._max_exponent: + self._max_exponent = min_exponent + + # Check if the value is too small to be rounded to the specified number of decimal + # places: + rounded = Helpers.round_to_n_decimal_places(self._value, -min_exponent) + if Decimal(rounded) == 0 and self._value != 0: + raise DecimalPlacesError() def get_min_exponent(self) -> int: return self._min_exponent @@ -57,10 +55,10 @@ def set_sigfigs(self, sigfigs: int): def is_exact(self) -> bool: return self._is_exact - def get(self) -> float: + def get(self) -> Decimal: return self._value - def get_abs(self) -> float: + def get_abs(self) -> Decimal: return abs(self._value) def get_exponent(self) -> int: @@ -71,6 +69,7 @@ def get_sig_figs(self) -> int: def get_decimal_place(self) -> int: if self._min_exponent is None: - raise RuntimeError("An unexpected error occurred. Please report this bug.") - else: - return -self._min_exponent + # This should not happen as `_min_exponent` should be set + # by the time this method is called. + raise RuntimeError(error_messages.INTERNAL_MIN_EXPONENT_ERROR) + return -self._min_exponent diff --git a/src/resultwizard/__init__.py b/src/resultwizard/__init__.py new file mode 100644 index 00000000..386f44b3 --- /dev/null +++ b/src/resultwizard/__init__.py @@ -0,0 +1,7 @@ +from api.config import config_init, config +from api.res import res +from api.export import export + +from api.tables.table import table +from api.tables.table_res import table_res +from api.tables.column import column diff --git a/src/valuewizard/__init__.py b/src/valuewizard/__init__.py deleted file mode 100644 index 8b46b34a..00000000 --- a/src/valuewizard/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from api.res import res -from api.export import export -from application.cache import _ResultsCache - -_res_cache = _ResultsCache() diff --git a/tests/number_word_test.py b/tests/number_word_test.py new file mode 100644 index 00000000..30541160 --- /dev/null +++ b/tests/number_word_test.py @@ -0,0 +1,31 @@ +import pytest + +from application.helpers import Helpers + + +class TestNumberWord: + @pytest.mark.parametrize( + "value, expected", + [ + (0, "zero"), + (1, "one"), + (2, "two"), + (3, "three"), + (10, "ten"), + (18, "eighteen"), + (76, "seventySix"), + (100, "oneHundred"), + (101, "oneHundredOne"), + (123, "oneHundredTwentyThree"), + (305, "threeHundredFive"), + (911, "nineHundredEleven"), + (999, "nineHundredNinetyNine"), + ], + ) + def test_number_to_word(self, value, expected): + assert Helpers.number_to_word(value) == expected + + @pytest.mark.parametrize("value", [1000, 1001, -1, -500]) + def test_number_to_word_raises(self, value): + with pytest.raises(ValueError, match="numbers between 0 and 999"): + Helpers.number_to_word(value) diff --git a/tests/parsers_test.py b/tests/parsers_test.py new file mode 100644 index 00000000..777a3b1e --- /dev/null +++ b/tests/parsers_test.py @@ -0,0 +1,95 @@ +from decimal import Decimal +import pytest + +from api import parsers +from domain.value import Value + + +class TestNameParser: + + @pytest.mark.parametrize( + "name, expected", + [ + ("a12", "aTwelve"), + ("a01", "aOne"), + ("042", "fortyTwo"), + ("a911bc13", "aNineHundredElevenBcThirteen"), + ("13_600", "thirteenSixHundred"), + ("a_5_6b7_$5", "aFiveSixBSevenFive"), + ], + ) + def test_substitutes_numbers(self, name: str, expected: str): + assert parsers.parse_name(name) == expected + + @pytest.mark.parametrize( + "name, expected", + [ + ("ä", "ae"), + ("Ä", "Ae"), + ("ü", "ue"), + ("Ü", "Ue"), + ("ö", "oe"), + ("Ö", "Oe"), + ("ß", "ss"), + ("ẞ", "SS"), + ("äh", "aeh"), + ("Füße", "Fuesse"), + ("GIEẞEN", "GIESSEN"), + ], + ) + def test_replaces_umlauts(self, name: str, expected: str): + assert parsers.parse_name(name) == expected + + @pytest.mark.parametrize( + "name, expected", + [ + ("!a$", "a"), + ("!a$b", "ab"), + ("!a$b", "ab"), + ("!%a&/(=*)s.,'@\"§d", "asd"), + ], + ) + def test_strips_invalid_chars(self, name: str, expected: str): + assert parsers.parse_name(name) == expected + + @pytest.mark.parametrize("name", ["", "!", " ", "_ ", "§ _ '*"]) + def test_empty_name_fails(self, name): + with pytest.raises(ValueError): + parsers.parse_name(name) + + +class TestValueParser: + + @pytest.mark.parametrize( + "value, expected", + [ + # plain numbers + ("012", Value(Decimal("12.00000000000"), min_exponent=0)), + ("12", Value(Decimal("12.0"), min_exponent=0)), + ("-42", Value(Decimal("-42"), min_exponent=0)), + ("10050", Value(Decimal("10050"), min_exponent=0)), + # plain numbers scientific + ("13e3", Value(Decimal("13000.0"), min_exponent=3)), + # plain decimal + ("3.1415", Value(Decimal("3.1415"), min_exponent=-4)), + ("0.005", Value(Decimal("0.005"), min_exponent=-3)), + ("0.010", Value(Decimal("0.01"), min_exponent=-3)), + # decimal & scientific + ("3.1415e3", Value(Decimal("3141.5"), min_exponent=-1)), + ("2.71828e2", Value(Decimal("271.828"), min_exponent=-3)), + ("2.5e-1", Value(Decimal("0.25"), min_exponent=-2)), + ("0.1e2", Value(Decimal("10.0"), min_exponent=1)), + ("1.2e5", Value(Decimal("120000.0"), min_exponent=4)), + ("1.20e5", Value(Decimal("120000.0"), min_exponent=3)), + ( + "103.1570e-30", + Value(Decimal("0.0000000000000000000000000001031570"), min_exponent=-34), + ), + ], + ) + def test_parse_exact_value(self, value: str, expected: Value): + v = parsers.parse_exact_value(value) + # pylint: disable=protected-access + assert v._value == expected._value + assert v._min_exponent == expected._min_exponent + # pylint: enable=protected-access diff --git a/tests/playground.py b/tests/playground.py index e1cb9318..ef533e19 100644 --- a/tests/playground.py +++ b/tests/playground.py @@ -6,21 +6,23 @@ # (e.g. the site-packages directory)." # From: https://setuptools.pypa.io/en/latest/userguide/quickstart.html#development-mode - -# TODO rename to ResultWizard -import valuewizard as wiz +from random import random +from decimal import Decimal +import resultwizard as wiz print("#############################") print("### Playground") print("#############################") print() - -# wiz.config( -# standard_sigfigs = 2, -# ... -# ) - +wiz.config_init( + print_auto=True, + export_auto_to="results-immediate.tex", + siunitx_fallback=False, + ignore_result_overwrite=False, +) +# wiz.config(sigfigs=2) +# wiz.config(decimal_places=2) ############################# # EXAMPLES @@ -28,48 +30,93 @@ print("### RESULTS API") -wiz.res("a1", 1.0, r"\mm").print() -# a: 1.0 \mm - -wiz.res("1 b", 1.0, 0.01, r"\per\mm\cubed").print() -# b: (1.0 ± 0.01) \mm - -wiz.res("c big", 1.0, (0.01, "systematic"), r"\mm").print() -# c: (1.0 ± 0.01 systematic) \mm +# wiz.res("", 42.0).print() +# -> Error: "name must not be empty" -wiz.res( - "d", 1.0e10, [(0.01e10, "systematic"), (0.0294999999e10, "stat")], r"\mm\per\second\squared" -).print() -# d: (1.0 ± 0.01 systematic ± 0.02 stat) \mm - -# wiz.standard_sigfigs(4) - -wiz.res("e", "1.0", r"\mm").print() -# e: 1.0 \mm +wiz.res("a911", 1.05, unit=r"\mm\s\per\N\kg") +# wiz.res("a911", "1.052", 0.25, r"\mm\s\per\N\kg") -# wiz.standard_sigfigs(3) +wiz.res("1 b", 1.0, 0.01, unit=r"\per\mm\cubed") -wiz.res("f", "1.0e4").print() -# f: 1.0 +# wiz.config(decimal_places=-1, sigfigs_fallback=3) -# wiz.res("g", 1.0, sys=0.01, stat=0.02, unit=r"\mm").print() -# g: (1.0 ± 0.01 sys ± 0.02 stat) \mm +wiz.res("c big", 1.0, (0.01, "systematic"), r"\mm") +wiz.res("d", 1.0e10, [(0.01e10, "sysyeah"), (0.0294999e10, "statyeah")], r"\mm\per\second^2") +# wiz.res("e", "1.0", r"\mm") # -> except error message that maybe we have forgotten to put `unit=` -# The following wont' work as we can't have positional arguments (here: unit) -# after keyword arguments (here: uncert) -# wiz.res("d", 1.0, uncert=[(0.01, "systematic"), (0.02, "stat")], r"\mm").print() - -# wiz.table( -# "name", -# { -# "Header 1": ["Test", "Test2", ...], -# "Header 2": [wiz.cell_res(...), wiz.cell_res(...), ...], -# "Header 3": [wiz.cell_res(values[i], errors[i], r"\mm") for i in range(10)], -# }, -# "description", -# horizontal = True, -# ) +wiz.res("f", "1.0e1", 25e-1) +wiz.res("g", 42) +wiz.res("h", 42, sys=13.0, stat=24.0) +wiz.res("h&", 42, sys=13.0, stat=24.0) +wiz.res("i", Decimal("42.0e-30"), Decimal("0.1e-31"), unit=r"\m") +wiz.res( + "i", + Decimal("42.0e-30"), + sys=Decimal("0.1e-31"), + stat=Decimal("0.05e-31"), + unit=r"\m\per\s\squared", +) +wiz.res("j", 0.009, None, "", 2) # really bad, but this is valid +# wiz.res("k", 1.55, 0.0, unit=r"\tesla") # -> uncertainty must be positive +wiz.res("k", 3, 1, r"\tesla") # integers work as well, yeah +wiz.res("l", 1.0, sys=0.01, stat=0.02, unit=r"\mm").print() +wiz.res("m", 1.0, uncerts=[(0.01, "systematic"), (0.02, "stat")], unit=r"\mm").print() + +wiz.table( + "name", + [ + wiz.column("Num.", [f"{i+1}" for i in range(10)]), + wiz.column( + "Random 1", [wiz.table_res(random(), random() * 0.1, r"\mm") for i in range(10)] + ), + wiz.column( + "Random 2", + [wiz.table_res(random(), random() * 0.1, r"\electronvolt") for i in range(10)], + concentrate_units_if_possible=False, + ), + wiz.column( + "Random 3", + [ + wiz.table_res( + random(), random() * 0.1, r"\electronvolt" if random() > 0.5 else r"\mm" + ) + for i in range(10) + ], + ), + ], + "description", + resize_to_fit_page_=True, +) + +wiz.table( + "name horizontal", + [ + wiz.column("Num.", [f"{i+1}" for i in range(4)]), + wiz.column("Random 1", [wiz.table_res(random(), random() * 0.1, r"\mm") for i in range(4)]), + wiz.column( + "Random 2", + [wiz.table_res(random(), random() * 0.1, r"\electronvolt") for i in range(4)], + concentrate_units_if_possible=False, + ), + wiz.column( + "Random 3", + [ + wiz.table_res( + random(), random() * 0.1, r"\electronvolt" if random() > 0.5 else r"\mm" + ) + for i in range(4) + ], + ), + ], + "description", + horizontal=True, + resize_to_fit_page_=True, + label="tab:horizontal", +) + +wiz.res("Tour Eiffel Height", "330.3141516", "0.5", r"\m") +wiz.res("g Another Test", 9.81, 0.78, unit=r"\m/\s^2") ############################# # Export diff --git a/tests/rounder_test.py b/tests/rounder_test.py index db5d47b6..9a78aa63 100644 --- a/tests/rounder_test.py +++ b/tests/rounder_test.py @@ -1,79 +1,221 @@ -from application.rounder import _Rounder -from domain.result import _Result -from domain.value import _Value -from domain.uncertainty import _Uncertainty +# pylint: disable=redefined-outer-name -class TestRounder: - def test_hierarchy_1(self): - res = _Result("", _Value("1.0000"), "", [], 2, 10) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -4 - - res = _Result("", _Value("1.0000"), "", [_Uncertainty(0.1)], None, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -4 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -4 - - res = _Result("", _Value("1.0000"), "", [_Uncertainty("0.1")], None, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -4 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -1 - - def test_hierarchy_2(self): - res = _Result("", _Value(1.0), "", [], 10, 2) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -9 - - res = _Result("", _Value(1.0), "", [_Uncertainty(0.1)], 10, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -9 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -9 +from typing import List +from decimal import Decimal +import pytest - res = _Result("", _Value(1.0), "", [_Uncertainty("0.1")], 10, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -9 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -1 +from application.rounder import Rounder, RoundingConfig +from domain.result import Result +from domain.value import Value +from domain.uncertainty import Uncertainty - def test_hierarchy_3(self): - res = _Result("", _Value(1.0), "", [], None, 2) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -2 - res = _Result("", _Value(1.0), "", [_Uncertainty(0.1)], None, 2) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -2 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -2 +@pytest.fixture +def config_defaults(): + return RoundingConfig(-1, -1, 2, -1) - res = _Result("", _Value(1.0), "", [_Uncertainty("0.1e-5")], None, 2) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -2 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -6 - def test_hierarchy_4(self): - res = _Result("", _Value(1.0), "", [_Uncertainty(0.11)], None, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -2 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -2 - - res = _Result("", _Value(1.0), "", [_Uncertainty(0.294999)], None, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -2 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -2 - - res = _Result("", _Value(1.0), "", [_Uncertainty(0.295001)], None, None) - _Rounder.round_result(res) - print(res.uncertainties[0].uncertainty.get_min_exponent()) - assert res.value.get_min_exponent() == -1 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -1 - - res = _Result("", _Value(1.0), "", [_Uncertainty(0.4), _Uncertainty(0.04)], None, None) - _Rounder.round_result(res) - print(res.uncertainties[0].uncertainty.get_min_exponent()) - assert res.value.get_min_exponent() == -2 - assert res.uncertainties[0].uncertainty.get_min_exponent() == -1 - assert res.uncertainties[1].uncertainty.get_min_exponent() == -2 +class TestRounder: - def test_hierarchy_5(self): - res = _Result("", _Value(1.0), "", [], None, None) - _Rounder.round_result(res) - assert res.value.get_min_exponent() == -1 + @pytest.mark.parametrize( + "result, config, expected_value_min_exponent, expected_uncert_min_exponents", + [ + # Hierarchy 1: + ( + Result("", Value(Decimal(1.0), -1), "", [], None, None), + RoundingConfig(-1, -1, 2, -1), + -1, + [], + ), + ( + Result( + "", Value(Decimal(1.0), -1), "", [Uncertainty(Value(Decimal(1.0)))], None, None + ), + RoundingConfig(-1, -1, 2, -1), + -1, + [-1], + ), + ( + Result( + "", + Value(Decimal(1.0), -1), + "", + [Uncertainty(Value(Decimal(1.0), -2))], + None, + None, + ), + RoundingConfig(-1, -1, 2, -1), + -1, + [-2], + ), + # Hierarchy 2: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0)))], + 3, + None, + ), + RoundingConfig(-1, -1, 2, -1), + -2, + [-2], + ), + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0), -3))], + 3, + None, + ), + RoundingConfig(-1, -1, 2, -1), + -2, + [-3], + ), + # Hierarchy 3: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0)))], + None, + 2, + ), + RoundingConfig(-1, -1, 2, -1), + -2, + [-2], + ), + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0), -3))], + None, + 2, + ), + RoundingConfig(-1, -1, 2, -1), + -2, + [-3], + ), + # Hierarchy 4: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0)))], + None, + None, + ), + RoundingConfig(5, -1, 2, -1), + -4, + [-4], + ), + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0), -3))], + None, + None, + ), + RoundingConfig(5, -1, 2, -1), + -4, + [-3], + ), + # Hierarchy 5: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0)))], + None, + None, + ), + RoundingConfig(-1, 4, 2, -1), + -4, + [-4], + ), + ( + Result( + "", + Value(Decimal(1.0)), + "", + [Uncertainty(Value(Decimal(1.0), -3))], + None, + None, + ), + RoundingConfig(-1, 4, 2, -1), + -4, + [-3], + ), + # Hierarchy 6: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [ + Uncertainty(Value(Decimal(1.0), -3)), + Uncertainty(Value(Decimal(1.0))), + Uncertainty(Value(Decimal(2.0))), + Uncertainty(Value(Decimal(2.9499))), + Uncertainty(Value(Decimal(2.9500))), + Uncertainty(Value(Decimal(0.007))), + ], + None, + None, + ), + RoundingConfig(-1, -1, 2, -1), + -3, + [-3, -1, -1, -1, 0, -3], + ), + # Hierarchy 7: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [], + None, + None, + ), + RoundingConfig(-1, -1, 2, -1), + -1, + [], + ), + # Hierarchy 8: + ( + Result( + "", + Value(Decimal(1.0)), + "", + [], + None, + None, + ), + RoundingConfig(-1, -1, -1, 2), + -2, + [], + ), + ], + ) + def test_all_hierarchies( + self, + result: Result, + config: RoundingConfig, + expected_value_min_exponent: int, + expected_uncert_min_exponents: List[int], + ): + Rounder.round_result(result, config) + assert result.value.get_min_exponent() == expected_value_min_exponent + assert [ + u.uncertainty.get_min_exponent() for u in result.uncertainties + ] == expected_uncert_min_exponents