├── .github └── workflows │ └── pythonpackage.yml ├── .gitignore ├── .pylintrc ├── .readthedocs.yaml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── .gitignore ├── Makefile ├── make.bat └── source │ ├── conf.py │ ├── data.rst │ ├── error_propagation.rst │ ├── fitting.rst │ ├── getting_started.nblink │ ├── index.rst │ ├── intro.rst │ ├── measurement_array.rst │ ├── measurements.rst │ ├── plotting.rst │ ├── plotting_and_fitting.nblink │ └── xydata.rst ├── examples ├── getting_started.ipynb └── plotting_and_fitting.ipynb ├── qexpy ├── __init__.py ├── data │ ├── __init__.py │ ├── data.py │ ├── datasets.py │ ├── operations.py │ └── utils.py ├── fitting │ ├── __init__.py │ ├── fitting.py │ └── utils.py ├── plotting │ ├── __init__.py │ ├── plotobjects.py │ └── plotting.py ├── settings │ ├── __init__.py │ ├── literals.py │ └── settings.py └── utils │ ├── __init__.py │ ├── exceptions.py │ ├── printing.py │ ├── units.py │ └── utils.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── .coveragerc ├── resources └── data_for_test_load_data.csv ├── test_datasets.py ├── test_error_propagation.py ├── test_fitting.py ├── test_measurements.py ├── test_operations.py ├── test_settings.py └── test_utils.py /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | python-version: ['3.7', '3.8', '3.9', '3.10'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install .[dev] 23 | - name: Test with pytest 24 | run: | 25 | pip install pytest 26 | pytest -v --durations=0 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | share/python-wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # PyInstaller 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | .pytest_cache/ 36 | 37 | # Sphinx documentation 38 | docs/_build/ 39 | 40 | # PyBuilder 41 | target/ 42 | 43 | # Jupyter Notebook 44 | .ipynb_checkpoints 45 | 46 | # IPython 47 | profile_default/ 48 | ipython_config.py 49 | 50 | # pyenv 51 | .python-version 52 | 53 | # Environments 54 | .DS_Store 55 | .idea 56 | .venv 57 | venv/ 58 | env.bak/ 59 | venv.bak/ 60 | .vscode/ 61 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS,venv 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 21 | # number of processors available to use. 22 | jobs=1 23 | 24 | # Control the amount of potential inferred values when inferring a single 25 | # object. This can help the performance when dealing with large functions or 26 | # complex, nested conditions. 27 | limit-inference-results=100 28 | 29 | # List of plugins (as comma separated values of python modules names) to load, 30 | # usually to register additional checkers. 31 | load-plugins= 32 | 33 | # Pickle collected data for later comparisons. 34 | persistent=yes 35 | 36 | # Specify a configuration file. 37 | #rcfile= 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape, 142 | wrong-import-order, 143 | ungrouped-imports, 144 | import-outside-toplevel, 145 | raise-missing-from 146 | 147 | # Enable the message, report, category or checker with the given id(s). You can 148 | # either give multiple identifier separated by comma (,) or put this option 149 | # multiple time (only on the command line, not in the configuration file where 150 | # it should appear only once). See also the "--disable" option for examples. 151 | enable=c-extension-no-member 152 | 153 | 154 | [REPORTS] 155 | 156 | # Python expression which should return a note less than 10 (10 is the highest 157 | # note). You have access to the variables errors warning, statement which 158 | # respectively contain the number of errors / warnings messages and the total 159 | # number of statements analyzed. This is used by the global evaluation report 160 | # (RP0004). 161 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 162 | 163 | # Template used to display messages. This is a python new-style format string 164 | # used to format the message information. See doc for all details. 165 | #msg-template= 166 | 167 | # Set the output format. Available formats are text, parseable, colorized, json 168 | # and msvs (visual studio). You can also give a reporter class, e.g. 169 | # mypackage.mymodule.MyReporterClass. 170 | output-format=text 171 | 172 | # Tells whether to display a full report or only the messages. 173 | reports=no 174 | 175 | # Activate the evaluation score. 176 | score=yes 177 | 178 | 179 | [REFACTORING] 180 | 181 | # Maximum number of nested blocks for function / method body 182 | max-nested-blocks=5 183 | 184 | # Complete name of functions that never returns. When checking for 185 | # inconsistent-return-statements if a never returning function is called then 186 | # it will be considered as an explicit return statement and no message will be 187 | # printed. 188 | never-returning-functions=sys.exit 189 | 190 | 191 | [LOGGING] 192 | 193 | # Format style used to check logging format string. `old` means using % 194 | # formatting, while `new` is for `{}` formatting. 195 | logging-format-style=old 196 | 197 | # Logging modules to check that the string format arguments are in logging 198 | # function parameter format. 199 | logging-modules=logging 200 | 201 | 202 | [SPELLING] 203 | 204 | # Limits count of emitted suggestions for spelling mistakes. 205 | max-spelling-suggestions=4 206 | 207 | # Spelling dictionary name. Available dictionaries: none. To make it working 208 | # install python-enchant package.. 209 | spelling-dict= 210 | 211 | # List of comma separated words that should not be checked. 212 | spelling-ignore-words= 213 | 214 | # A path to a file that contains private dictionary; one word per line. 215 | spelling-private-dict-file= 216 | 217 | # Tells whether to store unknown words to indicated private dictionary in 218 | # --spelling-private-dict-file option instead of raising a message. 219 | spelling-store-unknown-words=no 220 | 221 | 222 | [MISCELLANEOUS] 223 | 224 | # List of note tags to take in consideration, separated by a comma. 225 | notes=FIXME, 226 | XXX, 227 | 228 | 229 | [TYPECHECK] 230 | 231 | # List of decorators that produce context managers, such as 232 | # contextlib.contextmanager. Add to this list to register other decorators that 233 | # produce valid context managers. 234 | contextmanager-decorators=contextlib.contextmanager 235 | 236 | # List of members which are set dynamically and missed by pylint inference 237 | # system, and so shouldn't trigger E1101 when accessed. Python regular 238 | # expressions are accepted. 239 | generated-members= 240 | 241 | # Tells whether missing members accessed in mixin class should be ignored. A 242 | # mixin class is detected if its name ends with "mixin" (case insensitive). 243 | ignore-mixin-members=yes 244 | 245 | # Tells whether to warn about missing members when the owner of the attribute 246 | # is inferred to be None. 247 | ignore-none=yes 248 | 249 | # This flag controls whether pylint should warn about no-member and similar 250 | # checks whenever an opaque object is returned when inferring. The inference 251 | # can return multiple potential results while evaluating a Python object, but 252 | # some branches might not be evaluated, which results in partial inference. In 253 | # that case, it might be useful to still emit no-member and other checks for 254 | # the rest of the inferred objects. 255 | ignore-on-opaque-inference=yes 256 | 257 | # List of class names for which member attributes should not be checked (useful 258 | # for classes with dynamically set attributes). This supports the use of 259 | # qualified names. 260 | ignored-classes=optparse.Values,thread._local,_thread._local 261 | 262 | # List of module names for which member attributes should not be checked 263 | # (useful for modules/projects where namespaces are manipulated during runtime 264 | # and thus existing member attributes cannot be deduced by static analysis. It 265 | # supports qualified module names, as well as Unix pattern matching. 266 | ignored-modules= 267 | 268 | # Show a hint with possible names when a member name was not found. The aspect 269 | # of finding the hint is based on edit distance. 270 | missing-member-hint=yes 271 | 272 | # The minimum edit distance a name should have in order to be considered a 273 | # similar match for a missing member name. 274 | missing-member-hint-distance=1 275 | 276 | # The total number of similar names that should be taken in consideration when 277 | # showing a hint for a missing member. 278 | missing-member-max-choices=1 279 | 280 | 281 | [VARIABLES] 282 | 283 | # List of additional names supposed to be defined in builtins. Remember that 284 | # you should avoid defining new builtins when possible. 285 | additional-builtins= 286 | 287 | # Tells whether unused global variables should be treated as a violation. 288 | allow-global-unused-variables=yes 289 | 290 | # List of strings which can identify a callback function by name. A callback 291 | # name must start or end with one of those strings. 292 | callbacks=cb_, 293 | _cb 294 | 295 | # A regular expression matching the name of dummy variables (i.e. expected to 296 | # not be used). 297 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 298 | 299 | # Argument names that match this expression will be ignored. Default to name 300 | # with leading underscore. 301 | ignored-argument-names=_.*|^ignored_|^unused_ 302 | 303 | # Tells whether we should check for unused import in __init__ files. 304 | init-import=no 305 | 306 | # List of qualified module names which can have objects that can redefine 307 | # builtins. 308 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 309 | 310 | 311 | [FORMAT] 312 | 313 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 314 | expected-line-ending-format= 315 | 316 | # Regexp for a line that is allowed to be longer than the limit. 317 | ignore-long-lines=^\s*(# )??$ 318 | 319 | # Number of spaces of indent required inside a hanging or continued line. 320 | indent-after-paren=4 321 | 322 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 323 | # tab). 324 | indent-string=' ' 325 | 326 | # Maximum number of characters on a single line. 327 | max-line-length=100 328 | 329 | # Maximum number of lines in a module. 330 | max-module-lines=1500 331 | 332 | # List of optional constructs for which whitespace checking is disabled. `dict- 333 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 334 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 335 | # `empty-line` allows space-only lines. 336 | no-space-check=trailing-comma, 337 | dict-separator 338 | 339 | # Allow the body of a class to be on the same line as the declaration if body 340 | # contains single statement. 341 | single-line-class-stmt=no 342 | 343 | # Allow the body of an if to be on the same line as the test if there is no 344 | # else. 345 | single-line-if-stmt=no 346 | 347 | 348 | [SIMILARITIES] 349 | 350 | # Ignore comments when computing similarities. 351 | ignore-comments=yes 352 | 353 | # Ignore docstrings when computing similarities. 354 | ignore-docstrings=yes 355 | 356 | # Ignore imports when computing similarities. 357 | ignore-imports=no 358 | 359 | # Minimum lines number of a similarity. 360 | min-similarity-lines=4 361 | 362 | 363 | [BASIC] 364 | 365 | # Naming style matching correct argument names. 366 | argument-naming-style=snake_case 367 | 368 | # Regular expression matching correct argument names. Overrides argument- 369 | # naming-style. 370 | #argument-rgx= 371 | 372 | # Naming style matching correct attribute names. 373 | attr-naming-style=snake_case 374 | 375 | # Regular expression matching correct attribute names. Overrides attr-naming- 376 | # style. 377 | #attr-rgx= 378 | 379 | # Bad variable names which should always be refused, separated by a comma. 380 | bad-names=foo, 381 | bar, 382 | baz, 383 | toto, 384 | tutu, 385 | tata 386 | 387 | # Naming style matching correct class attribute names. 388 | class-attribute-naming-style=any 389 | 390 | # Regular expression matching correct class attribute names. Overrides class- 391 | # attribute-naming-style. 392 | #class-attribute-rgx= 393 | 394 | # Naming style matching correct class names. 395 | class-naming-style=PascalCase 396 | 397 | # Regular expression matching correct class names. Overrides class-naming- 398 | # style. 399 | #class-rgx= 400 | 401 | # Naming style matching correct constant names. 402 | const-naming-style=UPPER_CASE 403 | 404 | # Regular expression matching correct constant names. Overrides const-naming- 405 | # style. 406 | #const-rgx= 407 | 408 | # Minimum line length for functions/classes that require docstrings, shorter 409 | # ones are exempt. 410 | docstring-min-length=-1 411 | 412 | # Naming style matching correct function names. 413 | function-naming-style=snake_case 414 | 415 | # Regular expression matching correct function names. Overrides function- 416 | # naming-style. 417 | #function-rgx= 418 | 419 | # Good variable names which should always be accepted, separated by a comma. 420 | good-names=i, 421 | j, 422 | k, 423 | ex, 424 | Run, 425 | _, 426 | x0, 427 | dx, 428 | pm, 429 | pi, 430 | e, 431 | x, 432 | n, 433 | gs, 434 | ax, 435 | o, 436 | a, 437 | mc 438 | 439 | # Include a hint for the correct naming format with invalid-name. 440 | include-naming-hint=no 441 | 442 | # Naming style matching correct inline iteration names. 443 | inlinevar-naming-style=any 444 | 445 | # Regular expression matching correct inline iteration names. Overrides 446 | # inlinevar-naming-style. 447 | #inlinevar-rgx= 448 | 449 | # Naming style matching correct method names. 450 | method-naming-style=snake_case 451 | 452 | # Regular expression matching correct method names. Overrides method-naming- 453 | # style. 454 | #method-rgx= 455 | 456 | # Naming style matching correct module names. 457 | module-naming-style=snake_case 458 | 459 | # Regular expression matching correct module names. Overrides module-naming- 460 | # style. 461 | #module-rgx= 462 | 463 | # Colon-delimited sets of names that determine each other's naming style when 464 | # the name regexes allow several styles. 465 | name-group= 466 | 467 | # Regular expression which should only match function or class names that do 468 | # not require a docstring. 469 | no-docstring-rgx=^_ 470 | 471 | # List of decorators that produce properties, such as abc.abstractproperty. Add 472 | # to this list to register other decorators that produce valid properties. 473 | # These decorators are taken in consideration only for invalid-name. 474 | property-classes=abc.abstractproperty 475 | 476 | # Naming style matching correct variable names. 477 | variable-naming-style=snake_case 478 | 479 | # Regular expression matching correct variable names. Overrides variable- 480 | # naming-style. 481 | #variable-rgx= 482 | 483 | 484 | [STRING] 485 | 486 | # This flag controls whether the implicit-str-concat-in-sequence should 487 | # generate a warning on implicit string concatenation in sequences defined over 488 | # several lines. 489 | check-str-concat-over-line-jumps=no 490 | 491 | 492 | [IMPORTS] 493 | 494 | # Allow wildcard imports from modules that define __all__. 495 | allow-wildcard-with-all=no 496 | 497 | # Analyse import fallback blocks. This can be used to support both Python 2 and 498 | # 3 compatible code, which means that the block might have code that exists 499 | # only in one or another interpreter, leading to false positives when analysed. 500 | analyse-fallback-blocks=no 501 | 502 | # Deprecated modules which should not be used, separated by a comma. 503 | deprecated-modules=optparse,tkinter.tix 504 | 505 | # Create a graph of external dependencies in the given file (report RP0402 must 506 | # not be disabled). 507 | ext-import-graph= 508 | 509 | # Create a graph of every (i.e. internal and external) dependencies in the 510 | # given file (report RP0402 must not be disabled). 511 | import-graph= 512 | 513 | # Create a graph of internal dependencies in the given file (report RP0402 must 514 | # not be disabled). 515 | int-import-graph= 516 | 517 | # Force import order to recognize a module as part of the standard 518 | # compatibility libraries. 519 | known-standard-library= 520 | 521 | # Force import order to recognize a module as part of a third party library. 522 | known-third-party=enchant 523 | 524 | 525 | [CLASSES] 526 | 527 | # List of method names used to declare (i.e. assign) instance attributes. 528 | defining-attr-methods=__init__, 529 | __new__, 530 | setUp 531 | 532 | # List of member names, which should be excluded from the protected access 533 | # warning. 534 | exclude-protected=_asdict, 535 | _fields, 536 | _replace, 537 | _source, 538 | _make, 539 | _id, 540 | _formula, 541 | _unit 542 | 543 | # List of valid names for the first argument in a class method. 544 | valid-classmethod-first-arg=cls 545 | 546 | # List of valid names for the first argument in a metaclass class method. 547 | valid-metaclass-classmethod-first-arg=cls 548 | 549 | 550 | [DESIGN] 551 | 552 | # Maximum number of arguments for function / method. 553 | max-args=5 554 | 555 | # Maximum number of attributes for a class (see R0902). 556 | max-attributes=7 557 | 558 | # Maximum number of boolean expressions in an if statement. 559 | max-bool-expr=5 560 | 561 | # Maximum number of branch for function / method body. 562 | max-branches=12 563 | 564 | # Maximum number of locals for function / method body. 565 | max-locals=15 566 | 567 | # Maximum number of parents for a class (see R0901). 568 | max-parents=7 569 | 570 | # Maximum number of public methods for a class (see R0904). 571 | max-public-methods=20 572 | 573 | # Maximum number of return / yield for function / method body. 574 | max-returns=6 575 | 576 | # Maximum number of statements in function / method body. 577 | max-statements=50 578 | 579 | # Minimum number of public methods for a class (see R0903). 580 | min-public-methods=2 581 | 582 | 583 | [EXCEPTIONS] 584 | 585 | # Exceptions that will emit a warning when being caught. Defaults to 586 | # "BaseException, Exception". 587 | overgeneral-exceptions=BaseException, 588 | Exception 589 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | builder: html 11 | configuration: docs/source/conf.py 12 | 13 | # Optionally build your docs in additional formats such as PDF and ePub 14 | formats: all 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: 3.7 19 | install: 20 | - method: pip 21 | path: . 22 | extra_requirements: 23 | - doc 24 | 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: prep docs test 2 | 3 | prep: 4 | pip install -r requirements.txt 5 | pip install -e . 6 | 7 | docs: 8 | cd docs && make html 9 | open docs/build/html/index.html 10 | 11 | test: 12 | @echo 'Checking Code Styles' 13 | pylint qexpy 14 | @echo 'Running Unit Tests' 15 | cd tests && pytest -v --durations=0 16 | 17 | publish: 18 | pip install --upgrade pip setuptools wheel 19 | python setup.py sdist bdist_wheel 20 | pip install --upgrade twine 21 | twine upload dist/* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/Queens-Physics/qexpy/master) 2 | 3 | # QExPy 4 | 5 | Documentation: https://qexpy.readthedocs.io/en/latest/index.html 6 | 7 | ## Introduction 8 | 9 | QExPy (Queen’s Experimental Physics) is a python 3 package designed to facilitate data analysis in undergraduate physics laboratories. The package contains a module to easily propagate errors in uncertainty calculations, and a module that provides an intuitive interface to plot and fit data. The package is designed to be efficient, correct, and to allow for a pedagogic introduction to error analysis. The package is extensively tested in the Jupyter Notebook environment to allow high quality reports to be generated directly from a browser. 10 | 11 | ## Getting Started 12 | 13 | To install the package, type the following command in your terminal or [Anaconda](https://www.anaconda.com/distribution/#download-section) shell. 14 | 15 | ```sh 16 | $ pip install qexpy 17 | ``` 18 | 19 | ## Usage 20 | 21 | It's recommanded to use this package in the Jupyter Notebook environment. 22 | 23 | ```python 24 | import qexpy as q 25 | ``` 26 | 27 | ## Contributing 28 | 29 | With a local clone of this repository, if you wish to do development work, run the `make prep` in the project root directory, or run the following command explicitly: 30 | 31 | ```shell script 32 | pip install -r requirements.txt 33 | pip install -e . 34 | ``` 35 | 36 | This will install pytest which we use for testing, and pylint which we use to control code quality, as well as necessary packages for generating documentation. 37 | 38 | Before submitting any change, you should run `make test` in the project root directory to make sure that your code matches all code style requirements and passes all unit tests. 39 | 40 | The following command checks your code against all code style requirements: 41 | 42 | ```shell script 43 | pylint qexpy 44 | ``` 45 | 46 | Navigate to the tests directory, and execute the following command to run all unit tests: 47 | 48 | ```shell script 49 | pytest -v --durations=0 50 | ``` 51 | 52 | Documentation for this package is located in the docs directory. Run `make docs` in the project root directory to build the full documentation. The html page will open after the build is complete. 53 | 54 | Navigate to the docs directory, and run the following commands to build and see the full documentation page: 55 | 56 | ```shell script 57 | make html 58 | open docs/build/html/index.html 59 | ``` 60 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build/**/* -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath('.')) 19 | sys.path.insert(0, os.path.abspath('../..')) 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'QExPy' 24 | copyright = '2019, Astral Cai, Connor Kapahi, Prof. Ryan Martin' 25 | author = 'Astral Cai, Connor Kapahi, Prof. Ryan Martin' 26 | 27 | # The short X.Y version 28 | version = '' 29 | # The full version, including alpha/beta/rc tags 30 | release = '2.0.2' 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'nbsphinx', 44 | 'nbsphinx_link', 45 | 'sphinx.ext.mathjax', 46 | 'sphinx.ext.viewcode', 47 | 'sphinx.ext.napoleon', 48 | 'sphinx_autodoc_typehints', 49 | 'sphinx.ext.todo', 50 | 'IPython.sphinxext.ipython_console_highlighting' 51 | ] 52 | 53 | # Add any paths that contain templates here, relative to this directory. 54 | templates_path = ['_templates'] 55 | 56 | # The suffix(es) of source filenames. 57 | # You can specify multiple suffix as a list of string: 58 | # 59 | # source_suffix = ['.rst', '.md'] 60 | source_suffix = '.rst' 61 | 62 | # The master toctree document. 63 | master_doc = 'index' 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # 68 | # This is also used if you do content translation via gettext catalogs. 69 | # Usually you set "language" from the command line for these cases. 70 | language = None 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | # This pattern also affects html_static_path and html_extra_path. 75 | exclude_patterns = [] 76 | 77 | # The name of the Pygments (syntax highlighting) style to use. 78 | pygments_style = None 79 | 80 | # -- Options for HTML output ------------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | html_theme = 'sphinx_rtd_theme' 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ['_static'] 97 | 98 | # Custom sidebar templates, must be a dictionary that maps document names 99 | # to template names. 100 | # 101 | # The default sidebars (for documents that don't match any pattern) are 102 | # defined by theme itself. Builtin themes are using these templates by 103 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 104 | # 'searchbox.html']``. 105 | # 106 | # html_sidebars = {} 107 | 108 | 109 | # -- Options for HTMLHelp output --------------------------------------------- 110 | 111 | # Output file base name for HTML help builder. 112 | htmlhelp_basename = 'QExPydoc' 113 | 114 | # -- Options for LaTeX output ------------------------------------------------ 115 | 116 | latex_elements = { 117 | # The paper size ('letterpaper' or 'a4paper'). 118 | # 119 | # 'papersize': 'letterpaper', 120 | 121 | # The font size ('10pt', '11pt' or '12pt'). 122 | # 123 | # 'pointsize': '10pt', 124 | 125 | # Additional stuff for the LaTeX preamble. 126 | # 127 | # 'preamble': '', 128 | 129 | # Latex figure (float) alignment 130 | # 131 | # 'figure_align': 'htbp', 132 | } 133 | 134 | # Grouping the document tree into LaTeX files. List of tuples 135 | # (source start file, target name, title, 136 | # author, documentclass [howto, manual, or own class]). 137 | latex_documents = [ 138 | (master_doc, 'QExPy.tex', 'QExPy Documentation', 139 | 'Astral Cai, Connor Kapahi, Prof. Ryan Martin', 'manual'), 140 | ] 141 | 142 | # -- Options for manual page output ------------------------------------------ 143 | 144 | # One entry per manual page. List of tuples 145 | # (source start file, name, description, authors, manual section). 146 | man_pages = [ 147 | (master_doc, 'qexpy', 'QExPy Documentation', 148 | [author], 1) 149 | ] 150 | 151 | # -- Options for Texinfo output ---------------------------------------------- 152 | 153 | # Grouping the document tree into Texinfo files. List of tuples 154 | # (source start file, target name, title, author, 155 | # dir menu entry, description, category) 156 | texinfo_documents = [ 157 | (master_doc, 'QExPy', 'QExPy Documentation', 158 | author, 'QExPy', 'One line description of project.', 159 | 'Miscellaneous'), 160 | ] 161 | 162 | # -- Options for Epub output ------------------------------------------------- 163 | 164 | # Bibliographic Dublin Core info. 165 | epub_title = project 166 | 167 | # The unique identifier of the text. This can be a ISBN number 168 | # or the project homepage. 169 | # 170 | # epub_identifier = '' 171 | 172 | # A unique identification for the text. 173 | # 174 | # epub_uid = '' 175 | 176 | # A list of files that should not be packed into the epub file. 177 | epub_exclude_files = ['search.html'] 178 | 179 | # -- Extension configuration ------------------------------------------------- 180 | -------------------------------------------------------------------------------- /docs/source/data.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | The ExperimentalValue Object 3 | ============================ 4 | 5 | .. autoclass:: qexpy.data.data.ExperimentalValue 6 | 7 | Properties 8 | ---------- 9 | 10 | .. autoattribute:: qexpy.data.data.ExperimentalValue.value 11 | .. autoattribute:: qexpy.data.data.ExperimentalValue.error 12 | .. autoattribute:: qexpy.data.data.ExperimentalValue.relative_error 13 | .. autoattribute:: qexpy.data.data.ExperimentalValue.name 14 | .. autoattribute:: qexpy.data.data.ExperimentalValue.unit 15 | 16 | Methods 17 | ------- 18 | 19 | .. automethod:: qexpy.data.data.ExperimentalValue.derivative 20 | .. automethod:: qexpy.data.data.ExperimentalValue.get_covariance 21 | .. automethod:: qexpy.data.data.ExperimentalValue.set_covariance 22 | .. automethod:: qexpy.data.data.ExperimentalValue.get_correlation 23 | .. automethod:: qexpy.data.data.ExperimentalValue.set_correlation 24 | 25 | -------------------------------------------------------------------------------- /docs/source/error_propagation.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Error Propagation 3 | ================= 4 | 5 | Error propagation is implemented as a child class of :py:class:`ExperimentalValue` called :py:class:`DerivedValue`. When working with QExPy, the result of all computations are stored as instances of this class. 6 | 7 | The DerivedValue Object 8 | ======================= 9 | 10 | .. autoclass:: qexpy.data.data.DerivedValue 11 | 12 | Properties 13 | ---------- 14 | 15 | .. autoattribute:: qexpy.data.data.DerivedValue.value 16 | .. autoattribute:: qexpy.data.data.DerivedValue.error 17 | .. autoattribute:: qexpy.data.data.DerivedValue.relative_error 18 | .. autoattribute:: qexpy.data.data.DerivedValue.error_method 19 | .. autoattribute:: qexpy.data.data.DerivedValue.mc 20 | 21 | Methods 22 | ------- 23 | 24 | .. automethod:: qexpy.data.data.DerivedValue.reset_error_method 25 | .. automethod:: qexpy.data.data.DerivedValue.recalculate 26 | .. automethod:: qexpy.data.data.DerivedValue.show_error_contributions 27 | 28 | The MonteCarloSettings Object 29 | ============================= 30 | 31 | QExPy provides users with many options to customize Monte Carlo error propagation. Each :py:class:`DerivedValue` object stores a :py:class:`MonteCarloSettings` object that contains some settings for the Monte Carlo error propagation of this value. 32 | 33 | .. autoclass:: qexpy.data.utils.MonteCarloSettings 34 | 35 | Properties 36 | ---------- 37 | 38 | .. autoattribute:: qexpy.data.utils.MonteCarloSettings.sample_size 39 | .. autoattribute:: qexpy.data.utils.MonteCarloSettings.confidence 40 | .. autoattribute:: qexpy.data.utils.MonteCarloSettings.xrange 41 | 42 | Methods 43 | ------- 44 | 45 | .. automethod:: qexpy.data.utils.MonteCarloSettings.set_xrange 46 | .. automethod:: qexpy.data.utils.MonteCarloSettings.use_mode_with_confidence 47 | .. automethod:: qexpy.data.utils.MonteCarloSettings.use_mean_and_std 48 | .. automethod:: qexpy.data.utils.MonteCarloSettings.show_histogram 49 | .. automethod:: qexpy.data.utils.MonteCarloSettings.samples 50 | .. automethod:: qexpy.data.utils.MonteCarloSettings.use_custom_value_and_error 51 | -------------------------------------------------------------------------------- /docs/source/fitting.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | The Fitting Module 3 | ================== 4 | 5 | .. autofunction:: qexpy.fitting.fit 6 | 7 | The XYFitResult Class 8 | --------------------- 9 | 10 | .. autoclass:: qexpy.fitting.fitting.XYFitResult 11 | 12 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.dataset 13 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.fit_function 14 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.params 15 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.residuals 16 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.chi_squared 17 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.ndof 18 | .. autoattribute:: qexpy.fitting.fitting.XYFitResult.xrange 19 | 20 | -------------------------------------------------------------------------------- /docs/source/getting_started.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../examples/getting_started.ipynb" 3 | } -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. QExPy documentation master file 2 | 3 | Welcome to QExPy's documentation! 4 | ================================= 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | intro 11 | getting_started.nblink 12 | plotting_and_fitting.nblink 13 | data 14 | measurements 15 | measurement_array 16 | error_propagation 17 | xydata 18 | fitting 19 | plotting 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/source/intro.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Introduction 3 | ============ 4 | 5 | QExPy (Queen’s Experimental Physics) is a Python 3 package designed to facilitate data analysis in undergraduate physics laboratories. The package contains a module to easily propagate errors in uncertainty calculations, and a module that provides an intuitive interface to plot and fit data. The package is designed to be efficient, correct, and to allow for a pedagogic introduction to error analysis. The package is extensively tested in the Jupyter Notebook environment to allow high quality reports to be generated directly from a browser. 6 | 7 | Highlights: 8 | * Easily propagate uncertainties in calculations involving measured quantities 9 | * Compare different methods of error propagation (e.g. Quadrature errors, Monte Carlo errors) 10 | * Correctly include correlations between quantities when propagating uncertainties 11 | * Calculate derivatives of calculated values with respect to the measured quantities from which the value is derived 12 | * Flexible display formats for values and their uncertainties (e.g. number of significant figures, different ways of displaying units, scientific notation) 13 | * Smart unit tracking in calculations (in development) 14 | * Fit data to common functions (polynomials, gaussian distribution) or any custom functions specified by the user 15 | * Intuitive interface for data plotting built on matplotlib 16 | -------------------------------------------------------------------------------- /docs/source/measurement_array.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | The MeasurementArray Object 3 | =========================== 4 | 5 | Using QExPy, the user is able to record a series of measurements, and store them in an array. This feature is implemented in QExPy as a wrapper around :py:class:`numpy.ndarray`. The :py:class:`.ExperimentalValueArray` class, also given the alias :py:class:`.MeasurementArray` stores an array of values with uncertainties, and it also comes with methods for some basic data processing. 6 | 7 | .. autoclass:: qexpy.data.datasets.ExperimentalValueArray 8 | 9 | Properties 10 | ========== 11 | 12 | .. autoattribute:: qexpy.data.datasets.ExperimentalValueArray.values 13 | .. autoattribute:: qexpy.data.datasets.ExperimentalValueArray.errors 14 | .. autoattribute:: qexpy.data.datasets.ExperimentalValueArray.name 15 | .. autoattribute:: qexpy.data.datasets.ExperimentalValueArray.unit 16 | 17 | Methods 18 | ======= 19 | 20 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.mean 21 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.std 22 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.sum 23 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.error_on_mean 24 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.error_weighted_mean 25 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.propagated_error 26 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.append 27 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.delete 28 | .. automethod:: qexpy.data.datasets.ExperimentalValueArray.insert 29 | -------------------------------------------------------------------------------- /docs/source/measurements.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | The Measurement Object 3 | ====================== 4 | 5 | To record values with an uncertainty, we use the :py:class:`.MeasuredValue` object. It is a child class of :py:class:`.ExperimentalValue`, so it inherits all attributes and methods from the :py:class:`.ExperimentalValue` class. 6 | 7 | .. autoclass:: qexpy.data.data.MeasuredValue 8 | 9 | Repeated Measurements 10 | ===================== 11 | 12 | To record a value as the mean of a series of repeated measurements, use :py:class:`.RepeatedlyMeasuredValue` 13 | 14 | .. autoclass:: qexpy.data.data.RepeatedlyMeasuredValue 15 | 16 | Properties 17 | ---------- 18 | 19 | .. autoattribute:: qexpy.data.data.RepeatedlyMeasuredValue.raw_data 20 | .. autoattribute:: qexpy.data.data.RepeatedlyMeasuredValue.mean 21 | .. autoattribute:: qexpy.data.data.RepeatedlyMeasuredValue.error_weighted_mean 22 | .. autoattribute:: qexpy.data.data.RepeatedlyMeasuredValue.std 23 | .. autoattribute:: qexpy.data.data.RepeatedlyMeasuredValue.error_on_mean 24 | .. autoattribute:: qexpy.data.data.RepeatedlyMeasuredValue.propagated_error 25 | 26 | Methods 27 | ------- 28 | 29 | .. automethod:: qexpy.data.data.RepeatedlyMeasuredValue.use_std_for_uncertainty 30 | .. automethod:: qexpy.data.data.RepeatedlyMeasuredValue.use_error_on_mean_for_uncertainty 31 | .. automethod:: qexpy.data.data.RepeatedlyMeasuredValue.use_error_weighted_mean_as_value 32 | .. automethod:: qexpy.data.data.RepeatedlyMeasuredValue.use_propagated_error_for_uncertainty 33 | .. automethod:: qexpy.data.data.RepeatedlyMeasuredValue.show_histogram 34 | 35 | Correlated Measurements 36 | ======================= 37 | 38 | Sometimes in experiments, two measured quantities can be correlated, and this correlation needs to be accounted for during error propagation. QExPy provides methods that allows users to specify the correlation between two measurements, and it will be taken into account automatically during computations. 39 | 40 | .. autofunction:: qexpy.data.data.set_correlation 41 | .. autofunction:: qexpy.data.data.get_correlation 42 | .. autofunction:: qexpy.data.data.set_covariance 43 | .. autofunction:: qexpy.data.data.get_covariance 44 | 45 | There are also shortcuts to the above methods implemented in :py:class:`.ExperimentalValue`. 46 | 47 | .. automethod:: qexpy.data.data.MeasuredValue.set_correlation 48 | .. automethod:: qexpy.data.data.MeasuredValue.get_correlation 49 | .. automethod:: qexpy.data.data.MeasuredValue.set_covariance 50 | .. automethod:: qexpy.data.data.MeasuredValue.get_covariance 51 | -------------------------------------------------------------------------------- /docs/source/plotting.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | The Plotting Module 3 | =================== 4 | 5 | .. autofunction:: qexpy.plotting.plotting.plot 6 | .. autofunction:: qexpy.plotting.plotting.hist 7 | .. autofunction:: qexpy.plotting.plotting.show 8 | .. autofunction:: qexpy.plotting.plotting.savefig 9 | .. autofunction:: qexpy.plotting.plotting.get_plot 10 | .. autofunction:: qexpy.plotting.plotting.new_plot 11 | 12 | The Plot Object 13 | =============== 14 | 15 | .. autoclass:: qexpy.plotting.plotting.Plot 16 | 17 | Properties 18 | ---------- 19 | 20 | .. autoattribute:: qexpy.plotting.plotting.Plot.title 21 | .. autoattribute:: qexpy.plotting.plotting.Plot.xname 22 | .. autoattribute:: qexpy.plotting.plotting.Plot.yname 23 | .. autoattribute:: qexpy.plotting.plotting.Plot.xunit 24 | .. autoattribute:: qexpy.plotting.plotting.Plot.yunit 25 | .. autoattribute:: qexpy.plotting.plotting.Plot.xlabel 26 | .. autoattribute:: qexpy.plotting.plotting.Plot.ylabel 27 | .. autoattribute:: qexpy.plotting.plotting.Plot.xrange 28 | 29 | Methods 30 | ------- 31 | 32 | .. automethod:: qexpy.plotting.plotting.Plot.plot 33 | .. automethod:: qexpy.plotting.plotting.Plot.hist 34 | .. automethod:: qexpy.plotting.plotting.Plot.fit 35 | .. automethod:: qexpy.plotting.plotting.Plot.show 36 | .. automethod:: qexpy.plotting.plotting.Plot.legend 37 | .. automethod:: qexpy.plotting.plotting.Plot.error_bars 38 | .. automethod:: qexpy.plotting.plotting.Plot.residuals 39 | .. automethod:: qexpy.plotting.plotting.Plot.savefig 40 | -------------------------------------------------------------------------------- /docs/source/plotting_and_fitting.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../examples/plotting_and_fitting.ipynb" 3 | } -------------------------------------------------------------------------------- /docs/source/xydata.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | The XYDataSet Object 3 | ==================== 4 | 5 | .. autoclass:: qexpy.data.XYDataSet 6 | 7 | Properties 8 | ========== 9 | 10 | .. autoattribute:: qexpy.data.XYDataSet.xvalues 11 | .. autoattribute:: qexpy.data.XYDataSet.xerr 12 | .. autoattribute:: qexpy.data.XYDataSet.yvalues 13 | .. autoattribute:: qexpy.data.XYDataSet.yerr 14 | .. autoattribute:: qexpy.data.XYDataSet.xname 15 | .. autoattribute:: qexpy.data.XYDataSet.yname 16 | .. autoattribute:: qexpy.data.XYDataSet.xunit 17 | .. autoattribute:: qexpy.data.XYDataSet.yunit 18 | 19 | Methods 20 | ======= 21 | 22 | .. automethod:: qexpy.data.XYDataSet.fit 23 | -------------------------------------------------------------------------------- /qexpy/__init__.py: -------------------------------------------------------------------------------- 1 | """Python library for scientific data analysis""" 2 | 3 | # 4 | # _oo0oo_ 5 | # o8888888o 6 | # 88" . "88 7 | # (| -_- |) 8 | # 0\ = /0 9 | # ___/`---'\___ 10 | # .' \\| |// '. 11 | # / \\||| : |||// \ 12 | # / _||||| -:- |||||- \ 13 | # | | \\\ - /// | | 14 | # | \_| ''\---/'' |_/ | 15 | # \ .-\__ '-' ___/-. / 16 | # ___'. .' /--.--\ `. .'___ 17 | # ."" '< `.___\_<|>_/___.' >' "". 18 | # | | : `- \`.;`\ _ /`;.`/ - ` : | | 19 | # \ \ `_. \_ __\ /__ _/ .-` / / 20 | # =====`-.____`.___ \_____/___.-`___.-'===== 21 | # `=---=' 22 | # 23 | # 24 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | # 26 | # 佛祖保佑 永无BUG 27 | # 28 | 29 | import sys 30 | 31 | __version__ = '2.0.2' 32 | 33 | from .utils import load_data_from_file 34 | from .utils import define_unit, clear_unit_definitions 35 | 36 | from .settings import ErrorMethod, PrintStyle, UnitStyle, SigFigMode 37 | from .settings import get_settings, reset_default_configuration 38 | from .settings import set_sig_figs_for_value, set_sig_figs_for_error, set_error_method, \ 39 | set_print_style, set_unit_style, set_monte_carlo_sample_size, set_plot_dimensions 40 | 41 | from .data import Measurement, MeasurementArray, XYDataSet 42 | from .data import get_covariance, set_covariance, get_correlation, set_correlation 43 | from .data import sqrt, exp, sin, sind, cos, cosd, tan, tand, sec, secd, cot, cotd, \ 44 | csc, cscd, asin, acos, atan, log, log10, pi, e 45 | from .data import std, mean, sum # pylint: disable=redefined-builtin 46 | from .data import reset_correlations 47 | 48 | from .fitting import fit, FitModel 49 | 50 | # Check the python interpreter version 51 | if sys.version_info[0] < 3: # pragma: no coverage 52 | raise ImportError( 53 | "Error: QExPy is only supported on Python 3. Please upgrade your interpreter. " 54 | "If you're using Anaconda, you can download the correct version here: " 55 | "https://www.continuum.io/downloads") 56 | -------------------------------------------------------------------------------- /qexpy/data/__init__.py: -------------------------------------------------------------------------------- 1 | """This package contains the data structures and operations for experimental values""" 2 | 3 | from .data import MeasuredValue as Measurement 4 | from .datasets import ExperimentalValueArray as MeasurementArray, XYDataSet 5 | from .data import get_covariance, set_covariance, get_correlation, set_correlation 6 | from .data import reset_correlations 7 | from .operations import sqrt, exp, sin, sind, cos, cosd, tan, tand, sec, secd, cot, cotd, \ 8 | csc, cscd, asin, acos, atan, log, log10, pi, e 9 | from .operations import std, mean, sum_ as sum # pylint: disable=redefined-builtin 10 | -------------------------------------------------------------------------------- /qexpy/data/utils.py: -------------------------------------------------------------------------------- 1 | """Utility methods for the data module""" 2 | import warnings 3 | 4 | import numpy as np 5 | 6 | from numbers import Real 7 | 8 | from . import data as dt, datasets as dts # pylint: disable=cyclic-import 9 | 10 | import qexpy.settings.literals as lit 11 | import qexpy.settings as sts 12 | import qexpy.utils as utils 13 | 14 | ARRAY_TYPES = np.ndarray, list 15 | 16 | 17 | class MonteCarloSettings: 18 | """The object for customizing the Monte Carlo error propagation process""" 19 | 20 | def __init__(self, evaluator): 21 | self.__evaluator = evaluator 22 | self.__settings = { 23 | lit.MONTE_CARLO_SAMPLE_SIZE: 0, 24 | lit.MONTE_CARLO_STRATEGY: lit.MC_MEAN_AND_STD, 25 | lit.MONTE_CARLO_CONFIDENCE: 0.68, 26 | lit.XRANGE: () 27 | } 28 | 29 | @property 30 | def sample_size(self): 31 | """int: The Monte Carlo sample size""" 32 | default_size = sts.get_settings().monte_carlo_sample_size 33 | set_size = self.__settings[lit.MONTE_CARLO_SAMPLE_SIZE] 34 | return set_size if set_size else default_size 35 | 36 | @sample_size.setter 37 | def sample_size(self, new_size: int): 38 | if not isinstance(new_size, int) or new_size < 0: 39 | raise ValueError("The sample size has to be a positive integer") 40 | self.__settings[lit.MONTE_CARLO_SAMPLE_SIZE] = new_size 41 | self.__evaluator.clear() 42 | 43 | def reset_sample_size(self): 44 | """reset the sample size to default""" 45 | self.__settings[lit.MONTE_CARLO_SAMPLE_SIZE] = 0 46 | 47 | @property 48 | def confidence(self): 49 | """float: The confidence level for choosing the mode of a Monte Carlo distribution""" 50 | return self.__settings[lit.MONTE_CARLO_CONFIDENCE] 51 | 52 | @confidence.setter 53 | def confidence(self, new_level: float): 54 | if not isinstance(new_level, Real): 55 | raise TypeError("The MC confidence level has to be a number") 56 | if new_level > 1 or new_level < 0: 57 | raise ValueError("The MC confidence level has to be a number between 0 and 1") 58 | self.__settings[lit.MONTE_CARLO_CONFIDENCE] = new_level 59 | if lit.MC_MODE_AND_CONFIDENCE in self.__evaluator.values: 60 | self.__evaluator.values.pop(lit.MC_MODE_AND_CONFIDENCE) 61 | 62 | @property 63 | def xrange(self): 64 | """tuple: The x-range of the simulation 65 | 66 | This is really the y-range, which means it's the range of the y-values to show, 67 | but also this is the x-range of the histogram. 68 | 69 | """ 70 | return self.__settings[lit.XRANGE] 71 | 72 | def set_xrange(self, *args): 73 | """set the range for the monte carlo simulation""" 74 | 75 | if not args: 76 | self.__settings[lit.XRANGE] = () 77 | else: 78 | new_range = (args[0], args[1]) if len(args) > 1 else args 79 | utils.validate_xrange(new_range) 80 | self.__settings[lit.XRANGE] = new_range 81 | 82 | self.__evaluator.values.clear() 83 | 84 | def use_mode_with_confidence(self, confidence=None): 85 | """Use the mode of the distribution with a confidence coverage for this value""" 86 | self.__settings[lit.MONTE_CARLO_STRATEGY] = lit.MC_MODE_AND_CONFIDENCE 87 | if confidence: 88 | self.confidence = confidence 89 | 90 | def use_mean_and_std(self): 91 | """Use the mean and std of the distribution for this value""" 92 | self.__settings[lit.MONTE_CARLO_STRATEGY] = lit.MC_MEAN_AND_STD 93 | 94 | def use_custom_value_and_error(self, value, error): 95 | """Manually set the value and uncertainty for this quantity 96 | 97 | Sometimes when the distribution is not typical, and you wish to see for yourself what 98 | the best approach is to choose the center value and uncertainty for this quantity, 99 | use this method to manually set these values. 100 | 101 | """ 102 | self.__settings[lit.MONTE_CARLO_STRATEGY] = lit.MC_CUSTOM 103 | if not isinstance(value, Real): 104 | raise TypeError("Cannot assign a {} to the value!".format(type(value).__name__)) 105 | if not isinstance(error, Real): 106 | raise TypeError("Cannot assign a {} to the error!".format(type(error).__name__)) 107 | if error < 0: 108 | raise ValueError("The error must be a positive real number!") 109 | self.__evaluator.values[self.strategy] = dt.ValueWithError(value, error) 110 | 111 | @property 112 | def strategy(self): 113 | """str: the strategy used to extract value and error from a histogram""" 114 | return self.__settings[lit.MONTE_CARLO_STRATEGY] 115 | 116 | def show_histogram(self, bins=100, **kwargs): # pragma: no cover 117 | """Shows the distribution of the Monte Carlo simulated samples""" 118 | self.__evaluator.show_histogram(bins, **kwargs) 119 | 120 | def samples(self): 121 | """The raw samples generated in the Monte Carlo simulation 122 | 123 | Sometimes when the distribution is not typical, you might wish to do your own analysis 124 | with the raw samples generated in the Monte Carlo simulation. This method allows you 125 | to access a copy of the raw data. 126 | 127 | """ 128 | return self.__evaluator.raw_samples.copy() 129 | 130 | 131 | def generate_offset_matrix(measurements, sample_size): 132 | """Generates offsets from mean for each measurement 133 | 134 | Each sample set generated has 0 mean and unit variance. Then covariance is applied to the 135 | set of samples using the Chelosky algorithm. 136 | 137 | Args: 138 | measurements (List[dt.ExperimentalValue]): a set of measurements to simulate 139 | sample_size (int): the size of the samples 140 | 141 | Returns: 142 | A N row times M column matrix where N is the number of measurements to simulate and 143 | M is the requested sample size for Monte Carlo simulations. Each row of this matrix 144 | is an array of random values with 0 mean and unit variance 145 | 146 | """ 147 | 148 | offset_matrix = np.vstack( 149 | [np.random.normal(0, 1, sample_size) for _ in measurements]) 150 | offset_matrix = correlate_samples(measurements, offset_matrix) 151 | return offset_matrix 152 | 153 | 154 | def correlate_samples(variables, sample_vector): 155 | """Uses the Chelosky algorithm to add correlation to random samples 156 | 157 | This method finds the Chelosky decomposition of the correlation matrix of the given list 158 | of measurements, then applies it to the sample vector. 159 | 160 | The sample vector is a list of random samples, each entry correspond to each variable 161 | passed in. Each random sample, corresponding to each entry, is an array of random numbers 162 | with 0 mean and unit variance. 163 | 164 | Args: 165 | variables (List[dt.ExperimentalValue]): the source measurements 166 | sample_vector (np.ndarray): the list of random samples to apply correlation to 167 | 168 | Returns: 169 | The same list sample vector with correlation applied 170 | 171 | """ 172 | 173 | corr_matrix = np.array( 174 | [[dt.get_correlation(row, col) for col in variables] for row in variables]) 175 | if np.count_nonzero(corr_matrix - np.diag(np.diagonal(corr_matrix))) == 0: 176 | return sample_vector # if no correlations are present 177 | 178 | try: 179 | chelosky_decomposition = np.linalg.cholesky(corr_matrix) 180 | result_vector = np.dot(chelosky_decomposition, sample_vector) 181 | return result_vector 182 | except np.linalg.linalg.LinAlgError: # pragma: no cover 183 | warnings.warn( 184 | "Fail to generate a physical correlation matrix for the values provided, using " 185 | "uncorrelated samples instead. Please check that the covariance or correlation " 186 | "factors assigned to the measurements are physical.") 187 | return sample_vector 188 | 189 | 190 | def wrap_in_experimental_value(operand) -> "dt.ExperimentalValue": 191 | """Wraps a variable in an ExperimentalValue object 192 | 193 | Wraps single numbers in a Constant, number pairs in a MeasuredValue. If the argument 194 | is already an ExperimentalValue instance, return directly. If the 195 | 196 | """ 197 | 198 | if isinstance(operand, Real): 199 | return dt.Constant(operand) 200 | if isinstance(operand, dt.ExperimentalValue): 201 | return operand 202 | if isinstance(operand, tuple) and len(operand) == 2: 203 | return dt.MeasuredValue(operand[0], operand[1]) 204 | raise TypeError( 205 | "Cannot parse a {} into an ExperimentalValue".format(type(operand).__name__)) 206 | 207 | 208 | def wrap_in_measurement(value, **kwargs) -> "dt.ExperimentalValue": 209 | """Wraps a value in a Measurement object""" 210 | 211 | if isinstance(value, Real): 212 | return dt.MeasuredValue(value, 0, **kwargs) 213 | if isinstance(value, tuple) and len(value) == 2: 214 | return dt.MeasuredValue(*value, **kwargs) 215 | if isinstance(value, dt.ExperimentalValue): 216 | value.name = kwargs.get("name", "") 217 | value.unit = kwargs.get("unit", "") 218 | return value 219 | 220 | raise TypeError( 221 | "Elements of a MeasurementArray must be convertible to an ExperimentalValue") 222 | 223 | 224 | def wrap_in_value_array(operand, **kwargs) -> np.ndarray: 225 | """Wraps input in an ExperimentalValueArray""" 226 | 227 | # wrap array times in numpy arrays 228 | if isinstance(operand, dts.ExperimentalValueArray): 229 | return operand 230 | if isinstance(operand, ARRAY_TYPES): 231 | return np.asarray([wrap_in_measurement(value, **kwargs) for value in operand]) 232 | 233 | # wrap single value times in array 234 | return np.asarray([wrap_in_measurement(operand, **kwargs)]) 235 | -------------------------------------------------------------------------------- /qexpy/fitting/__init__.py: -------------------------------------------------------------------------------- 1 | """This package contains fitting functions for data sets""" 2 | 3 | from .utils import FitModel 4 | from .fitting import fit 5 | -------------------------------------------------------------------------------- /qexpy/fitting/fitting.py: -------------------------------------------------------------------------------- 1 | """This module contains curve fitting functions""" 2 | 3 | import inspect 4 | import numpy as np 5 | import scipy.optimize as opt 6 | 7 | from typing import Callable 8 | from inspect import Parameter 9 | from collections import namedtuple 10 | from qexpy.utils.exceptions import IllegalArgumentError 11 | from .utils import FitModelInfo, FitParamConstraints 12 | 13 | import qexpy.data.data as dt 14 | import qexpy.data.datasets as dts 15 | import qexpy.settings.literals as lit 16 | import qexpy.utils as utils 17 | 18 | from . import utils as fut 19 | 20 | # container for the raw outputs of a fit 21 | RawFitResults = namedtuple("RawFitResults", "popt, perr, pcov") 22 | 23 | # container for fit results 24 | FitResults = namedtuple("FitResults", "func, params, residuals, chi2, pcorr") 25 | 26 | ARRAY_TYPES = np.ndarray, list 27 | 28 | 29 | class XYFitResult: 30 | """Stores the results of a curve fit""" 31 | 32 | def __init__(self, **kwargs): 33 | """Constructor for an XYFitResult object""" 34 | 35 | self._dataset = kwargs.pop("dataset") 36 | self._model = kwargs.pop("model") 37 | self._xrange = kwargs.pop("xrange") 38 | 39 | result_func = kwargs.pop("res_func") 40 | result_params = kwargs.pop("res_params") 41 | pcorr = kwargs.pop("pcorr") 42 | 43 | y_fit_res = result_func(self._dataset.xdata) 44 | self._ndof = len(y_fit_res) - len(result_params) - 1 45 | 46 | y_err = self._dataset.ydata - y_fit_res 47 | 48 | chi2 = sum( 49 | (res.value / err) ** 2 for res, err in zip(y_err, self._dataset.yerr) if err != 0) 50 | 51 | self._result = FitResults(result_func, result_params, y_err, chi2, pcorr) 52 | 53 | def __getitem__(self, index): 54 | return self._result.params[index] 55 | 56 | def __str__(self): 57 | header = "----------------- Fit Results -------------------" 58 | fit_type = "Fit of {} to {}\n".format(self._dataset.name, self._model.name) 59 | res_params = map(str, self._result.params) 60 | res_param_str = "Result Parameter List: \n{}\n".format(",\n".join(res_params)) 61 | corr_matrix = np.array_str(self._result.pcorr, precision=3) 62 | corr_matrix_str = "Correlation Matrix: \n{}\n".format(corr_matrix) 63 | chi2_ndof = "chi2/ndof = {:.2f}/{}\n".format(self._result.chi2, self._ndof) 64 | ending = "--------------- End Fit Results -----------------" 65 | return "\n".join( 66 | [header, fit_type, res_param_str, corr_matrix_str, chi2_ndof, ending]) 67 | 68 | @property 69 | def dataset(self): 70 | """dts.XYDataSet: The dataset used for this fit""" 71 | return self._dataset 72 | 73 | @property 74 | def fit_function(self): 75 | """Callable: The function that fits to this data set""" 76 | return self._result.func 77 | 78 | @property 79 | def params(self): 80 | """List[dt.ExperimentalValue]: The fit parameters of the fit function""" 81 | return self._result.params 82 | 83 | @property 84 | def residuals(self): 85 | """dts.ExperimentalValueArray: The residuals of the fit""" 86 | return self._result.residuals 87 | 88 | @property 89 | def chi_squared(self): 90 | """dt.ExperimentalValue: The goodness of fit represented as chi^2""" 91 | return self._result.chi2 92 | 93 | @property 94 | def ndof(self): 95 | """int: The degree of freedom of this fit function""" 96 | return self._ndof 97 | 98 | @property 99 | def xrange(self): 100 | """tuple: The xrange of the fit""" 101 | return self._xrange 102 | 103 | 104 | def fit(*args, **kwargs) -> XYFitResult: 105 | """Perform a fit to a data set 106 | 107 | The fit function can be called on an XYDataSet object, or two arrays or MeasurementArray 108 | objects. QExPy provides 5 builtin fit models, which includes linear fit, quadratic fit, 109 | general polynomial fit, gaussian fit, and exponential fit. The user can also pass in a 110 | custom function they wish to fit their dataset on. For non-polynomial fit functions, the 111 | user would usually need to pass in an array of guesses for the parameters. 112 | 113 | Args: 114 | *args: An XYDataSet object or two arrays to be fitted. 115 | 116 | Keyword Args: 117 | model: the fit model given as the string or enum representation of a pre-set model 118 | or a custom callable function with parameters. Available pre-set models include: 119 | "linear", "quadratic", "polynomial", "exponential", "gaussian" 120 | xrange (tuple|list): a pair of numbers indicating the domain of the function 121 | degrees (int): the degree of the polynomial if polynomial fit were chosen 122 | parguess (list): initial guess for the parameters 123 | parnames (list): the names of each parameter 124 | parunits (list): the units for each parameter 125 | dataset: the XYDataSet instance to fit on 126 | xdata : the x-data of the fit 127 | ydata: the y-data of the fit 128 | xerr: the uncertainty on the xdata 129 | yerr: the uncertainty on the ydata 130 | 131 | Returns: 132 | XYFitResult: the result of the fit 133 | 134 | See Also: 135 | :py:class:`~qexpy.data.XYDataSet` 136 | 137 | """ 138 | 139 | result = __try_fit_to_xy_dataset(*args, **kwargs) 140 | if result: 141 | return result 142 | 143 | result = __try_fit_to_xdata_and_ydata(*args, **kwargs) 144 | if result: 145 | return result 146 | 147 | raise IllegalArgumentError( 148 | "Unable to execute fit. Please make sure the arguments provided are correct.") 149 | 150 | 151 | def fit_to_xy_dataset(dataset: dts.XYDataSet, model, **kwargs) -> XYFitResult: 152 | """Perform a fit on an XYDataSet object""" 153 | 154 | fit_model = fut.prepare_fit_model(model) 155 | 156 | if fit_model.name == lit.POLY: 157 | # By default, the degree of a polynomial fit model is 3, because if it were 2, the 158 | # quadratic fit model would've been chosen. The number of parameters is the degree 159 | # of the fit model plus one. (e.g. a degree-1, or linear fit, has 2 params) 160 | new_constraints = FitParamConstraints(kwargs.get("degrees", 3) + 1, False, False) 161 | fit_model = FitModelInfo(fit_model.name, fit_model.func, new_constraints) 162 | 163 | param_info, fit_model = fut.prepare_param_info(fit_model, **kwargs) 164 | 165 | xrange = kwargs.get("xrange", None) 166 | if xrange and utils.validate_xrange(xrange): 167 | x_to_fit = dataset.xdata[(xrange[0] <= dataset.xdata) & (dataset.xdata < xrange[1])] 168 | y_to_fit = dataset.ydata[(xrange[0] <= dataset.xdata) & (dataset.xdata < xrange[1])] 169 | else: 170 | x_to_fit = dataset.xdata 171 | y_to_fit = dataset.ydata 172 | 173 | yerr = y_to_fit.errors if any(err > 0 for err in y_to_fit.errors) else None 174 | 175 | if fit_model.name in [lit.POLY, lit.LIN, lit.QUAD]: 176 | raw_res = __polynomial_fit( 177 | x_to_fit, y_to_fit, fit_model.param_constraints.length - 1, yerr) 178 | else: 179 | raw_res = __curve_fit( 180 | fit_model.func, x_to_fit, y_to_fit, param_info.parguess, yerr) 181 | 182 | # wrap the parameters in MeasuredValue objects 183 | def wrap_param_in_measurements(): 184 | par_res = zip(raw_res.popt, raw_res.perr, param_info.parunits, param_info.parnames) 185 | for param, err, unit, name in par_res: 186 | yield dt.MeasuredValue(param, err, unit=unit, name=name) 187 | 188 | params = list(wrap_param_in_measurements()) 189 | 190 | pcorr = utils.cov2corr(raw_res.pcov) 191 | __correlate_fit_params(params, raw_res.pcov) 192 | 193 | # wrap the result function with the params 194 | result_func = __combine_fit_func_and_fit_params(fit_model.func, params) 195 | 196 | return XYFitResult(dataset=dataset, model=fit_model, res_func=result_func, 197 | res_params=params, pcorr=pcorr, xrange=xrange) 198 | 199 | 200 | def __try_fit_to_xy_dataset(*args, **kwargs): 201 | """Helper function to parse the inputs to a call to fit() for a single XYDataSet""" 202 | 203 | dataset = kwargs.pop("dataset", args[0] if args else None) 204 | model = kwargs.pop("model", args[1] if len(args) > 1 else None) 205 | 206 | if isinstance(dataset, dts.XYDataSet) and model: 207 | return fit_to_xy_dataset(dataset, model, **kwargs) 208 | 209 | return None 210 | 211 | 212 | def __try_fit_to_xdata_and_ydata(*args, **kwargs): 213 | """Helper function to parse the inputs to a call to fit() for separate xdata and ydata""" 214 | 215 | xdata = kwargs.pop("xdata", args[0] if args else None) 216 | ydata = kwargs.pop("ydata", args[1] if len(args) > 1 else None) 217 | model = kwargs.pop("model", args[2] if len(args) > 2 else None) 218 | 219 | if not isinstance(xdata, dts.ExperimentalValueArray): 220 | xdata = np.asarray(xdata) if isinstance(xdata, ARRAY_TYPES) else np.empty(0) 221 | 222 | if not isinstance(ydata, dts.ExperimentalValueArray): 223 | ydata = np.asarray(ydata) if isinstance(ydata, ARRAY_TYPES) else np.empty(0) 224 | 225 | if xdata.size and ydata.size and model: 226 | return fit_to_xy_dataset(dts.XYDataSet(xdata, ydata, **kwargs), model, **kwargs) 227 | 228 | return None 229 | 230 | 231 | def __polynomial_fit(xdata, ydata, degrees, yerr) -> RawFitResults: 232 | """perform a polynomial fit with numpy.polyfit""" 233 | 234 | weights = 1 / yerr if yerr is not None else None 235 | popt, pcov = np.polyfit(xdata.values, ydata.values, degrees, cov=True, w=weights) 236 | perr = np.sqrt(np.diag(pcov)) 237 | return RawFitResults(popt, perr, pcov) 238 | 239 | 240 | def __curve_fit(fit_func, xdata, ydata, parguess, yerr) -> RawFitResults: 241 | """perform a regular curve fit with scipy.optimize.curve_fit""" 242 | 243 | try: 244 | popt, pcov = opt.curve_fit( # pylint:disable=unbalanced-tuple-unpacking 245 | fit_func, 246 | xdata.values, 247 | ydata.values, 248 | p0=parguess, 249 | sigma=yerr, 250 | absolute_sigma=True 251 | ) 252 | 253 | # adjust the fit by factoring in the uncertainty on x 254 | if any(err > 0 for err in xdata.errors): 255 | func = __combine_fit_func_and_fit_params(fit_func, popt) 256 | yerr = 0 if yerr is None else yerr 257 | adjusted_yerr = np.sqrt( 258 | yerr ** 2 + (xdata.errors * utils.numerical_derivative(func, xdata.errors))**2) 259 | 260 | # re-calculate the fit with adjusted uncertainties for ydata 261 | popt, pcov = opt.curve_fit( # pylint:disable=unbalanced-tuple-unpacking 262 | fit_func, 263 | xdata.values, 264 | ydata.values, 265 | p0=parguess, 266 | sigma=adjusted_yerr, 267 | absolute_sigma=True 268 | ) 269 | 270 | except RuntimeError: # pragma: no cover 271 | 272 | # Re-write the error message so that it can be more easily understood by the user 273 | raise RuntimeError( 274 | "Fit could not converge. Please check that the fit model is well defined, and " 275 | "that the parameter guess as well as the y-errors are appropriate.") 276 | 277 | # The error on the parameters 278 | perr = np.sqrt(np.diag(pcov)) 279 | 280 | return RawFitResults(popt, perr, pcov) 281 | 282 | 283 | def __combine_fit_func_and_fit_params(func: Callable, params) -> Callable: 284 | """wraps a function with params to a function of x""" 285 | 286 | result_func = utils.vectorize(lambda x: func(x, *params)) 287 | 288 | # Change signature of the function to match the actual signature 289 | sig = inspect.signature(result_func) 290 | new_sig = sig.replace(parameters=[Parameter("x", Parameter.POSITIONAL_ONLY)]) 291 | result_func.__signature__ = new_sig 292 | 293 | return result_func 294 | 295 | 296 | def __correlate_fit_params(params, corr): 297 | """Apply correlation to the list of parameters with the covariance matrix""" 298 | 299 | for index1, param1 in enumerate(params): 300 | for index2, param2 in enumerate(params[index1 + 1:]): 301 | if param1.error == 0 or param2.error == 0: # pragma: no cover 302 | continue 303 | param1.set_covariance(param2, corr[index1][index2 + index1 + 1]) 304 | -------------------------------------------------------------------------------- /qexpy/fitting/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the fit module""" 2 | import functools 3 | import inspect 4 | import warnings 5 | 6 | from collections import namedtuple 7 | 8 | # Contains the name, callable fit function, and the constraints on the fit parameters 9 | from enum import Enum 10 | from numbers import Real 11 | from inspect import Parameter 12 | from typing import Callable, List 13 | 14 | from qexpy.data import operations as op 15 | from qexpy.settings import literals as lit 16 | from qexpy.utils import IllegalArgumentError 17 | 18 | # Contains the name, callable function, and parameter constraints on a fit model 19 | FitModelInfo = namedtuple("FitModelInfo", "name, func, param_constraints") 20 | 21 | # Contains constraints on fit parameters, including the number of params required, a flag 22 | # indicating if there is a variable position argument in the fit function, which indicates 23 | # that the actual number of fit parameters might be higher than what it looks, and a flag 24 | # stating if guesses are required to execute this fit 25 | FitParamConstraints = namedtuple("FitParamConstraints", "length, var_len, guess_required") 26 | 27 | # Contains the parameter information used in a fit 28 | FitParamInfo = namedtuple("FitParamInfo", "parguess, parnames, parunits") 29 | 30 | 31 | class FitModel(Enum): 32 | """QExPy supported pre-set fit models""" 33 | LINEAR = lit.LIN 34 | QUADRATIC = lit.QUAD 35 | POLYNOMIAL = lit.POLY 36 | GAUSSIAN = lit.GAUSS 37 | EXPONENTIAL = lit.EXPO 38 | 39 | 40 | def prepare_fit_model(model) -> FitModelInfo: 41 | """Prepares the fit model and fit function for a fit 42 | 43 | Args: 44 | model: the fit model as is passed into the fit function 45 | 46 | Returns: 47 | model (FitModelInfo): the fit model and all information related to it 48 | 49 | """ 50 | 51 | # First find the name and the callable fit function for the model 52 | if isinstance(model, str) and model in FITTERS: 53 | name, func = model, FITTERS[model] 54 | elif isinstance(model, FitModel): 55 | name, func = model.value, FITTERS[model.value] 56 | elif callable(model): 57 | name, func = "custom", model 58 | else: 59 | raise ValueError( 60 | "Invalid fit model specified! The fit model can be one of the following: " 61 | "one of the pre-set fit models in the form of a string or chosen from the " 62 | "q.FitModel enum, or a custom callable fit function") 63 | 64 | # Now find the number of parameters this fit function has 65 | params = list(inspect.signature(func).parameters.values()) 66 | 67 | if any(arg.kind in [Parameter.KEYWORD_ONLY, Parameter.VAR_KEYWORD] for arg in params): 68 | raise ValueError("The fit function should not have keyword arguments") 69 | 70 | # If the last param is variable positional, the actual number of params may be higher 71 | var_pos_present = params[-1].kind == Parameter.VAR_POSITIONAL 72 | 73 | # The first argument of the fit function is the variable, only the rest are parameters 74 | nr_of_params = len(params) - 1 75 | 76 | if nr_of_params == 0: 77 | raise ValueError("The number of parameters in the given fit model is 0!") 78 | 79 | guess_required = name not in [lit.LIN, lit.QUAD, lit.POLY] 80 | constraints = FitParamConstraints(nr_of_params, var_pos_present, guess_required) 81 | 82 | return FitModelInfo(name, func, constraints) 83 | 84 | 85 | def prepare_param_info(model: FitModelInfo, **kwargs) -> (FitParamInfo, FitModelInfo): 86 | """Prepares the parameter information for a fit function 87 | 88 | Args: 89 | model (FitModelInfo): the fit model used for this fit 90 | 91 | Keyword Args: 92 | parguess: the vector of parameter guesses 93 | parnames: the vector of parameter names 94 | parunits: the vector of parameter units 95 | 96 | Returns: 97 | The first return is a FitParamInfo which includes: parguess, parnames, parunits, 98 | all read from kwargs and validated against the fit model and constraints. The last 99 | return value would be the updated FitModelInfo based on all the parameter info. 100 | 101 | """ 102 | 103 | constraints = model.param_constraints 104 | 105 | # check if guess parameters are provided 106 | parguess = kwargs.get("parguess", None) 107 | if constraints.guess_required and parguess is None: 108 | warnings.warn( 109 | "You have not provided any guesses of parameters for a {} fit. For this type " 110 | "of fitting, it is recommended to specify parguess".format(model.name)) 111 | 112 | validate_param_info(parguess, "parguess", constraints) 113 | 114 | if parguess is not None: 115 | # The length of the parguess vector dictates the number of parameters 116 | constraints = FitParamConstraints(len(parguess), False, True) 117 | model = FitModelInfo(model.name, model.func, constraints) 118 | 119 | if parguess and any(not isinstance(guess, Real) for guess in parguess): 120 | raise TypeError("The guess parameters provided are not real numbers!") 121 | 122 | parnames = kwargs.get("parnames", prepare_param_names(model)) 123 | validate_param_info(parnames, "parnames", constraints) 124 | if parnames and any(not isinstance(name, str) for name in parnames): 125 | raise TypeError("The parameter names provided are not strings!") 126 | 127 | parunits = kwargs.get("parunits", [""] * constraints.length) 128 | validate_param_info(parunits, "parunits", constraints) 129 | if parunits and any(not isinstance(unit, str) for unit in parunits): 130 | raise TypeError("The parameter units provided are not strings!") 131 | 132 | return FitParamInfo(parguess, parnames, parunits), model 133 | 134 | 135 | def validate_param_info(info, info_name: str, constraints: FitParamConstraints): 136 | """Validates the param information is valid and matches the fit model""" 137 | 138 | if not info: 139 | return # skip if there's nothing to check 140 | 141 | if not isinstance(info, (list, tuple)): 142 | raise IllegalArgumentError("\"{}\" has to be a list or a tuple.".format(info_name)) 143 | 144 | if constraints.var_len and len(info) < constraints.length: 145 | raise ValueError( 146 | "The length of \"{}\" ({}) doesn't match the number of parameters in the fit " 147 | "function ({} or higher)".format(info_name, len(info), constraints.length)) 148 | 149 | if not constraints.var_len and len(info) != constraints.length: 150 | raise ValueError( 151 | "The length of \"{}\" ({}) doesn't match the number of parameters in the fit " 152 | "function (expecting {})".format(info_name, len(info), constraints.length)) 153 | 154 | 155 | def prepare_param_names(model: FitModelInfo): 156 | """Finds the default param names for pre-set fit models""" 157 | 158 | if model.name in DEFAULT_PARNAMES: 159 | return DEFAULT_PARNAMES.get(model.name) 160 | 161 | nr_of_params = model.param_constraints.length 162 | 163 | # check the function signature for custom functions 164 | par_names = get_param_names_from_signature(model.func, nr_of_params) 165 | 166 | return par_names if par_names else [""] * nr_of_params 167 | 168 | 169 | def get_param_names_from_signature(func: Callable, nr_of_params: int) -> List: 170 | """Inspect the signature of the custom function for parameter names""" 171 | 172 | # get all arguments to the function except for the first one (the variable) 173 | params = list(inspect.signature(func).parameters.values())[1:] 174 | 175 | # the last parameter could be variable, so we process the rest of the parameters first 176 | param_names = list(param.name for param in params[:-1]) 177 | 178 | # now process the last parameter 179 | if params[-1].kind == Parameter.VAR_POSITIONAL: 180 | left_overs = nr_of_params - len(param_names) # how many params left to be filled 181 | last_params = list("{}_{}".format(params[-1].name, idx) for idx in range(left_overs)) 182 | else: 183 | last_params = [params[-1].name] 184 | 185 | param_names.extend(last_params) 186 | 187 | return param_names 188 | 189 | 190 | FITTERS = { 191 | lit.LIN: lambda x, a, b: a * x + b, 192 | lit.QUAD: lambda x, a, b, c: a * x ** 2 + b * x + c, 193 | lit.POLY: lambda x, *coeffs: functools.reduce(lambda a, b: a * x + b, reversed(coeffs)), 194 | lit.EXPO: lambda x, c, a: c * op.exp(-a * x), 195 | lit.GAUSS: lambda x, norm, mean, std: norm / op.sqrt( 196 | 2 * op.pi * std ** 2) * op.exp(-1 / 2 * (x - mean) ** 2 / std ** 2) 197 | } 198 | 199 | DEFAULT_PARNAMES = { 200 | lit.LIN: ["slope", "intercept"], 201 | lit.EXPO: ["amplitude", "decay constant"], 202 | lit.GAUSS: ["normalization", "mean", "std"] 203 | } 204 | -------------------------------------------------------------------------------- /qexpy/plotting/__init__.py: -------------------------------------------------------------------------------- 1 | """This package contains plotting functions""" 2 | 3 | from .plotting import plot, hist, show, new_plot, get_plot, savefig 4 | -------------------------------------------------------------------------------- /qexpy/plotting/plotobjects.py: -------------------------------------------------------------------------------- 1 | """Contains definitions for objects to be drawn on plot""" 2 | 3 | import numpy as np 4 | import inspect 5 | 6 | from abc import ABC, abstractmethod 7 | from matplotlib.pyplot import Axes 8 | from qexpy.utils.exceptions import IllegalArgumentError, UndefinedActionError 9 | from qexpy.fitting.fitting import XYFitResult 10 | 11 | import qexpy.data.data as dt 12 | import qexpy.utils as uts 13 | import qexpy.settings.settings as sts 14 | import qexpy.settings.literals as lit 15 | import qexpy.data.datasets as dts 16 | 17 | from . import plotting as plt # pylint: disable=cyclic-import,unused-import 18 | 19 | 20 | class ObjectOnPlot(ABC): 21 | """A container for anything to be plotted""" 22 | 23 | def __init__(self, *args, **kwargs): 24 | """Constructor for ObjectOnPlot""" 25 | 26 | # process format string 27 | fmt = kwargs.pop("fmt", args[0] if args and isinstance(args[0], str) else None) 28 | if fmt and not isinstance(fmt, str): 29 | raise TypeError("The fmt provided is not a string!") 30 | self._fmt = fmt 31 | 32 | # process color 33 | color = kwargs.pop("color", None) 34 | if color and not isinstance(color, str): 35 | raise TypeError("The color provided is not a string!") 36 | self._color = color 37 | 38 | # add the rest to the object 39 | label = kwargs.pop("label", "") 40 | if label and not isinstance(label, str): 41 | raise TypeError("The label of this plot object is not a string!") 42 | self.label = label 43 | 44 | @property 45 | def fmt(self): 46 | """str: The format string to be used in PyPlot""" 47 | return self._fmt 48 | 49 | @property 50 | def color(self): 51 | """str: The color of the object""" 52 | return self._color 53 | 54 | @color.setter 55 | def color(self, new_color: str): 56 | if not new_color: 57 | return 58 | if not isinstance(new_color, str): 59 | raise TypeError("The color has to be a string.") 60 | self._color = new_color 61 | 62 | @abstractmethod 63 | def show(self, ax: Axes, plot: "plt.Plot"): 64 | """Draw the object itself onto the given axes""" 65 | raise NotImplementedError 66 | 67 | 68 | class FitTarget(ABC): # pylint: disable=too-few-public-methods 69 | """Interface for anything to which a fit can be applied""" 70 | 71 | @property 72 | @abstractmethod 73 | def fit_target_dataset(self): 74 | """dts.XYDataSet: The target dataset instance to apply the fit to""" 75 | raise NotImplementedError 76 | 77 | 78 | class ObjectWithRange(ABC): # pylint: disable=too-few-public-methods 79 | """Interface for anything with an xrange""" 80 | 81 | @property 82 | @abstractmethod 83 | def xrange(self): 84 | """tuple: The xrange of the object""" 85 | raise NotImplementedError 86 | 87 | 88 | class XYObjectOnPlot(ObjectOnPlot, ObjectWithRange): 89 | """A container for objects with x and y values to be drawn on a plot""" 90 | 91 | def __init__(self, *args, **kwargs): 92 | """Constructor for XYObjectOnPlot""" 93 | 94 | xrange = kwargs.pop("xrange", ()) 95 | if xrange: 96 | uts.validate_xrange(xrange) 97 | self._xrange = xrange 98 | 99 | xname = kwargs.pop("xname", "") 100 | if not isinstance(xname, str): 101 | raise TypeError("The xname provided is not a string!") 102 | self._xname = xname 103 | 104 | yname = kwargs.pop("yname", "") 105 | if not isinstance(yname, str): 106 | raise TypeError("The yname provided is not a string!") 107 | self._yname = yname 108 | 109 | xunit = kwargs.pop("xunit", "") 110 | if not isinstance(xunit, str): 111 | raise TypeError("The xunit provided is not a string!") 112 | self._xunit = xunit 113 | 114 | yunit = kwargs.pop("yunit", "") 115 | if not isinstance(yname, str): 116 | raise TypeError("The yunit provided is not a string!") 117 | self._yunit = yunit 118 | 119 | # save the plot kwargs 120 | self.plot_kwargs = {k: v for k, v in kwargs.items() if k in PLOT_VALID_KWARGS} 121 | self.err_kwargs = {k: v for k, v in kwargs.items() if k in ERRORBAR_VALID_KWARGS} 122 | 123 | super().__init__(*args, **kwargs) 124 | 125 | @property 126 | @abstractmethod 127 | def xvalues(self): 128 | """np.ndarray: The array of x-values to be plotted""" 129 | raise NotImplementedError 130 | 131 | @property 132 | @abstractmethod 133 | def yvalues(self): 134 | """np.ndarray: The array of y-values to be plotted""" 135 | raise NotImplementedError 136 | 137 | @property 138 | def xrange(self): 139 | """tuple: The range of values to be plotted""" 140 | return self._xrange 141 | 142 | @xrange.setter 143 | def xrange(self, new_range: tuple): 144 | if new_range: 145 | uts.validate_xrange(new_range) 146 | self._xrange = new_range 147 | 148 | @property 149 | def xname(self): 150 | """str: The name of the x-axis""" 151 | return self._xname 152 | 153 | @property 154 | def xunit(self): 155 | """str: The unit of the x-axis""" 156 | return self._xunit 157 | 158 | @property 159 | def yname(self): 160 | """str: The name of the x-axis""" 161 | return self._yname 162 | 163 | @property 164 | def yunit(self): 165 | """str: The unit of the x-axis""" 166 | return self._yunit 167 | 168 | 169 | class XYDataSetOnPlot(XYObjectOnPlot, FitTarget): 170 | """A wrapper for an XYDataSet to be plotted""" 171 | 172 | def __init__(self, *args, **kwargs): 173 | 174 | # set the data set object 175 | if args and isinstance(args[0], dts.XYDataSet): 176 | self.dataset = args[0] 177 | fmt = kwargs.pop("fmt", args[1] if len(args) >= 2 else "") 178 | else: 179 | self.dataset = dts.XYDataSet(*args, **kwargs) 180 | fmt = kwargs.pop("fmt", "") 181 | 182 | label = kwargs.pop("label", self.dataset.name) 183 | fmt = fmt if fmt else "o" 184 | 185 | # call super constructors 186 | XYObjectOnPlot.__init__(self, label=label, fmt=fmt, **kwargs) 187 | 188 | def show(self, ax: Axes, plot: "plt.Plot"): 189 | if not plot.plot_settings[lit.ERROR_BAR]: 190 | ax.plot( 191 | self.xvalues, self.yvalues, self.fmt, color=self.color, 192 | label=self.label, **self.plot_kwargs) 193 | else: 194 | ax.errorbar( 195 | self.xvalues, self.yvalues, self.yerr, self.xerr, fmt=self.fmt, 196 | color=self.color, label=self.label, **self.plot_kwargs, **self.err_kwargs) 197 | 198 | @property 199 | def xrange(self): 200 | if not self._xrange: 201 | return min(self.dataset.xvalues), max(self.dataset.xvalues) 202 | return self._xrange 203 | 204 | @property 205 | def xvalues(self): 206 | if self._xrange: 207 | return self.dataset.xvalues[self.__get_indices_from_xrange()] 208 | return self.dataset.xvalues 209 | 210 | @property 211 | def yvalues(self): 212 | if self._xrange: 213 | return self.dataset.yvalues[self.__get_indices_from_xrange()] 214 | return self.dataset.yvalues 215 | 216 | @property 217 | def xerr(self): 218 | """np.ndarray: the array of x-value uncertainties to show up on plot""" 219 | if self._xrange: 220 | return self.dataset.xerr[self.__get_indices_from_xrange()] 221 | return self.dataset.xerr 222 | 223 | @property 224 | def yerr(self): 225 | """np.ndarray: the array of y-value uncertainties to show up on plot""" 226 | if self._xrange: 227 | return self.dataset.yerr[self.__get_indices_from_xrange()] 228 | return self.dataset.yerr 229 | 230 | @property 231 | def xname(self): 232 | return self.dataset.xname 233 | 234 | @property 235 | def xunit(self): 236 | return self.dataset.xunit 237 | 238 | @property 239 | def yname(self): 240 | return self.dataset.yname 241 | 242 | @property 243 | def yunit(self): 244 | return self.dataset.yunit 245 | 246 | @property 247 | def fit_target_dataset(self) -> dts.XYDataSet: 248 | return self.dataset 249 | 250 | def __get_indices_from_xrange(self): 251 | low, high = self._xrange 252 | return (low <= self.dataset.xvalues) & (self.dataset.xvalues < high) 253 | 254 | 255 | class FunctionOnPlot(XYObjectOnPlot): 256 | """This is the wrapper for a function to be plotted""" 257 | 258 | def __init__(self, *args, **kwargs): 259 | """Constructor for FunctionOnPlot""" 260 | 261 | func = args[0] if args else None 262 | 263 | # check input 264 | if not callable(func): 265 | raise IllegalArgumentError("The function provided is not a callable object!") 266 | 267 | # this checks if the xrange of plot is specified by user or auto-generated 268 | self.xrange_specified = "xrange" in kwargs 269 | 270 | self.pars = kwargs.pop("pars", []) 271 | 272 | self.error_method = kwargs.pop("error_method", None) 273 | 274 | self._ydata = None # buffer for calculated y data 275 | 276 | parameters = inspect.signature(func).parameters 277 | if len(parameters) > 1 and not self.pars: 278 | raise ValueError( 279 | "For a function with parameters, a list of parameters has to be supplied.") 280 | 281 | if len(parameters) == 1: 282 | self.func = func 283 | elif len(parameters) > 1: 284 | self.func = lambda x: func(x, *self.pars) # pylint:disable=not-callable 285 | else: 286 | raise ValueError("The function supplied does not have an x-variable.") 287 | 288 | XYObjectOnPlot.__init__(self, *args, **kwargs) 289 | 290 | def __call__(self, *args, **kwargs): 291 | return self.func(*args, **kwargs) 292 | 293 | def show(self, ax: Axes, plot: "plt.Plot"): 294 | xvalues = self.xvalues 295 | yvalues = self.yvalues 296 | ax.plot( 297 | xvalues, yvalues, self.fmt if self.fmt else "-", color=self.color, 298 | label=self.label, **self.plot_kwargs) 299 | yerr = self.yerr 300 | if yerr.size > 0 and plot.plot_settings[lit.ERROR_BAR]: 301 | max_vals = yvalues + yerr 302 | min_vals = yvalues - yerr 303 | ax.fill_between( 304 | xvalues, min_vals, max_vals, edgecolor='none', color=self.color, 305 | alpha=0.3, interpolate=True, zorder=0) 306 | 307 | @property 308 | def xrange(self): 309 | return self._xrange 310 | 311 | @xrange.setter 312 | def xrange(self, new_range: tuple): 313 | if new_range: 314 | uts.validate_xrange(new_range) 315 | self._xrange = new_range 316 | self._ydata = None # clear y data since it would need to be re-calculated 317 | 318 | @property 319 | def xvalues(self): 320 | if not self.xrange: 321 | raise UndefinedActionError("The domain of this function cannot be found.") 322 | return np.linspace(self.xrange[0], self.xrange[1], 100) 323 | 324 | @property 325 | def ydata(self): 326 | """The raw y data of the function""" 327 | if self._ydata: 328 | return self._ydata 329 | if not self.xrange: 330 | raise UndefinedActionError("The domain of this function cannot be found.") 331 | result = self.func(self.xvalues) 332 | derived_values = (res for res in result if isinstance(res, dt.DerivedValue)) 333 | if self.error_method: 334 | for value in derived_values: 335 | value.error_method = self.error_method 336 | return result 337 | 338 | @property 339 | @sts.use_mc_sample_size(10000) 340 | def yvalues(self): 341 | simplified_result = list( 342 | res.value if isinstance(res, dt.DerivedValue) else res for res in self.ydata) 343 | return np.asarray(simplified_result) 344 | 345 | @property 346 | @sts.use_mc_sample_size(10000) 347 | def yerr(self): 348 | """The array of y-value uncertainties to show up on plot""" 349 | errors = np.asarray(list( 350 | res.error if isinstance(res, dt.DerivedValue) else 0 for res in self.ydata)) 351 | return errors if errors.size else np.empty(0) 352 | 353 | 354 | class XYFitResultOnPlot(ObjectOnPlot, ObjectWithRange): 355 | """Wrapper for an XYFitResult to be plotted""" 356 | 357 | def __init__(self, *args, **kwargs): 358 | """Constructor for an XYFitResultOnPlot""" 359 | 360 | result = args[0] if args else None 361 | 362 | # check input 363 | if not isinstance(result, XYFitResult): 364 | raise IllegalArgumentError("The fit result is not an XYFitResult instance") 365 | 366 | # initialize object 367 | ObjectOnPlot.__init__(self, **kwargs) 368 | self.fit_result = result 369 | 370 | self._xrange = result.xrange if result.xrange else ( 371 | min(result.dataset.xvalues), max(result.dataset.xvalues)) 372 | 373 | self.func_on_plot = FunctionOnPlot( 374 | result.fit_function, xrange=self._xrange, error_method=lit.MONTE_CARLO, **kwargs) 375 | self.residuals_on_plot = XYDataSetOnPlot( 376 | result.dataset.xdata, result.residuals, **kwargs) 377 | 378 | # pylint: disable=protected-access 379 | def show(self, ax: Axes, plot: "plt.Plot"): 380 | if not self.color: 381 | datasets = (obj for obj in plot._objects if isinstance(obj, XYDataSetOnPlot)) 382 | color = next(( 383 | obj.color for obj in datasets if obj.dataset == self.fit_result.dataset), "") 384 | self.color = color if color else plot._color_palette.pop(0) 385 | self.func_on_plot.show(ax, plot) 386 | if plot.res_ax: 387 | self.residuals_on_plot.show(plot.res_ax, plot) 388 | 389 | @property 390 | def color(self): 391 | return self._color 392 | 393 | @color.setter 394 | def color(self, new_color: str): 395 | if not new_color: 396 | return 397 | if not isinstance(new_color, str): 398 | raise TypeError("The color has to be a string.") 399 | self._color = new_color 400 | self.func_on_plot.color = new_color 401 | self.residuals_on_plot.color = new_color 402 | 403 | @property 404 | def dataset(self): 405 | """dts.XYDataSet: The dataset that the fit is associated with""" 406 | return self.fit_result.dataset 407 | 408 | @property 409 | def xrange(self): 410 | return self._xrange 411 | 412 | 413 | class HistogramOnPlot(ObjectOnPlot, FitTarget, ObjectWithRange): 414 | """Represents a histogram to be drawn on a plot""" 415 | 416 | def __init__(self, *args, **kwargs): 417 | """Constructor for histogram on plots""" 418 | 419 | ObjectOnPlot.__init__(self, **kwargs) 420 | 421 | if args and isinstance(args[0], dts.ExperimentalValueArray): 422 | self.samples = args[0] 423 | else: 424 | self.samples = dts.ExperimentalValueArray(*args, **kwargs) 425 | 426 | self.kwargs = {k: v for k, v in kwargs.items() if k in HIST_VALID_KWARGS} 427 | 428 | hist_kwargs = {k: v for k, v in kwargs.items() if k in NP_HIST_VALID_KWARGS} 429 | self.n, self.bin_edges = np.histogram(self.samples.values, **hist_kwargs) 430 | 431 | self._xrange = self.bin_edges[0], self.bin_edges[-1] 432 | 433 | def show(self, ax: Axes, plot: "plt.Plot"): 434 | ax.hist(self.sample_values, **self.kwargs) 435 | 436 | @property 437 | def sample_values(self): 438 | """np.ndarray: The values of the samples in this histogram""" 439 | return self.samples.values 440 | 441 | @property 442 | def fit_target_dataset(self) -> dts.XYDataSet: 443 | bins = self.bin_edges 444 | xvalues = [(bins[i] + bins[i + 1]) / 2 for i in range(len(bins) - 1)] 445 | return dts.XYDataSet(xvalues, self.n, name="histogram") 446 | 447 | @property 448 | def xrange(self) -> (float, float): 449 | return self._xrange 450 | 451 | 452 | # Valid keyword arguments for pyplot.plot() 453 | PLOT_VALID_KWARGS = [ 454 | "agg_filter", "alpha", "animated", "antialiased", "clip_box", "clip_on", "clip_path", 455 | "lw", "contains", "dash_capstyle", "dash_joinstyle", "dashes", "drawstyle", "figure", 456 | "fillstyle", "gid", "in_layout", "linestyle", "linewidth", "marker", "ls", "ds", 457 | "markeredgecolor", "markeredgewidth", "markerfacecolor", "markersize", "mfc", "mew", 458 | "markerfacecoloralt", "markevery", "path_effects", "picker", "pickradius", "rasterized", 459 | "sketch_params", "snap", "solid_capstyle", "solid_joinstyle", "transform", "url", 460 | "visible", "zorder", "aa", "c", "ms", "mfcalt", "mec" 461 | ] 462 | 463 | # Valid keyword arguments for pyplot.errorbar() 464 | ERRORBAR_VALID_KWARGS = [ 465 | "ecolor", "elinewidth", "capsize", "capthick", "barsabove", "lolims", "uplims", 466 | "xlolims", "xuplims", "errorevery" 467 | ] 468 | 469 | # Valid keyword arguments for pyplot.hist() 470 | HIST_VALID_KWARGS = [ 471 | "bins", "range", "density", "weights", "cumulative", "bottom", "align", "histtype", 472 | "orientation", "rwidth", "log", "stacked", "agg_filter", "alpha", "animated", 473 | "antialiased", "aa", "capstyle", "clip_box", "clip_on", "clip_path", "color", 474 | "contains", "edgecolor", "ec", "facecolor", "fc", "figure", "fill", "gid", "hatch", 475 | "in_layout", "joinstyle", "label", "linestyle", "ls", "linewidth", "lw", "path_effects", 476 | "picker", "rasterized", "sketch_params", "snap", "transform", "url", "visible", "zorder" 477 | ] 478 | 479 | # Valid keyword arguments for numpy.histogram() 480 | NP_HIST_VALID_KWARGS = ["bins", "range", "density", "weights"] 481 | -------------------------------------------------------------------------------- /qexpy/plotting/plotting.py: -------------------------------------------------------------------------------- 1 | """This file contains function definitions for plotting""" 2 | 3 | import matplotlib.pyplot as plt 4 | 5 | from typing import List 6 | from qexpy.utils.exceptions import IllegalArgumentError, UndefinedActionError 7 | from .plotobjects import ObjectOnPlot, XYObjectOnPlot, XYDataSetOnPlot, FunctionOnPlot, \ 8 | XYFitResultOnPlot, HistogramOnPlot, FitTarget, ObjectWithRange 9 | 10 | import qexpy.utils as utils 11 | import qexpy.fitting as ft 12 | import qexpy.settings as sts 13 | import qexpy.settings.literals as lit 14 | 15 | 16 | class Plot: 17 | """The data structure used for a plot""" 18 | 19 | # points to the latest Plot instance that's created 20 | current_plot_buffer = None # type: Plot 21 | 22 | def __init__(self): 23 | self._objects = [] # type: List[ObjectOnPlot] 24 | self._plot_info = { 25 | lit.TITLE: "", 26 | lit.XNAME: "", 27 | lit.YNAME: "", 28 | lit.XUNIT: "", 29 | lit.YUNIT: "" 30 | } 31 | self.plot_settings = { 32 | lit.LEGEND: False, 33 | lit.ERROR_BAR: True, 34 | lit.RESIDUALS: False, 35 | lit.PLOT_STYLE: lit.DEFAULT, 36 | } 37 | self._color_palette = ["C{}".format(idx) for idx in range(20)] 38 | self._xrange = () 39 | self.main_ax = None 40 | self.res_ax = None 41 | 42 | def plot(self, *args, **kwargs): 43 | """Adds a data set or function to the plot 44 | 45 | See Also: 46 | :py:func:`.plot` 47 | 48 | """ 49 | new_obj = self.__create_object_on_plot(*args, **kwargs) 50 | self._objects.append(new_obj) 51 | 52 | def hist(self, *args, **kwargs): 53 | """Adds a histogram to the plot 54 | 55 | See Also: 56 | :py:func:`.hist` 57 | 58 | """ 59 | 60 | new_obj = HistogramOnPlot(*args, **kwargs) 61 | 62 | # add color to the histogram 63 | color = kwargs.pop("color", self._color_palette.pop(0)) 64 | new_obj.color = color 65 | 66 | self._objects.append(new_obj) 67 | 68 | return new_obj.n, new_obj.bin_edges 69 | 70 | def fit(self, *args, **kwargs): 71 | """Plots a curve fit to the last data set added to the figure 72 | 73 | The fit function finds the last data set or histogram added to the Plot and apply a 74 | fit to it. This function takes the same arguments as QExPy fit function, and the same 75 | keyword arguments as in the QExPy plot function in configuring how the line of best 76 | fit shows up on the plot. 77 | 78 | See Also: 79 | :py:func:`~qexpy.fitting.fit` 80 | :py:func:`.plot` 81 | 82 | """ 83 | 84 | fit_targets = list(_obj for _obj in self._objects if isinstance(_obj, FitTarget)) 85 | target = next(reversed(fit_targets), None) 86 | 87 | if not target: 88 | raise UndefinedActionError("There is no dataset in this plot to be fitted.") 89 | 90 | result = ft.fit(target.fit_target_dataset, *args, **kwargs) 91 | color = kwargs.pop( 92 | "color", target.color if isinstance(target, ObjectOnPlot) else "") 93 | obj = self.__create_object_on_plot(result, color=color, **kwargs) 94 | 95 | if isinstance(target, HistogramOnPlot) and isinstance(obj, XYFitResultOnPlot): 96 | target.kwargs["alpha"] = 0.8 97 | obj.func_on_plot.plot_kwargs["lw"] = 2 98 | 99 | self._objects.append(obj) 100 | return result 101 | 102 | def __prepare_fig(self): 103 | """Prepare figure before showing or saving it""" 104 | self.__setup_figure_and_subplots() 105 | 106 | # set the xrange of functions to plot using the range of existing data sets 107 | xrange = self.xrange 108 | for obj in self._objects: 109 | if isinstance(obj, FunctionOnPlot) and not obj.xrange_specified: 110 | obj.xrange = xrange 111 | 112 | for obj in self._objects: 113 | obj.show(self.main_ax, self) 114 | 115 | self.main_ax.set_title(self.title) 116 | self.main_ax.set_xlabel(self.xlabel) 117 | self.main_ax.set_ylabel(self.ylabel) 118 | self.main_ax.grid() 119 | 120 | if self.res_ax: 121 | self.res_ax.set_xlabel(self.xlabel) 122 | self.res_ax.set_ylabel("residuals") 123 | self.res_ax.grid() 124 | 125 | if self.plot_settings[lit.LEGEND]: 126 | self.main_ax.legend() # show legend if requested 127 | 128 | def show(self): 129 | """Draws the plot to output""" 130 | 131 | self.__prepare_fig() 132 | plt.show() 133 | 134 | def savefig(self, filename, **kwargs): 135 | """Save figure using matplotlib""" 136 | 137 | self.__prepare_fig() 138 | plt.savefig(filename, **kwargs) 139 | 140 | def legend(self, new_setting=True): 141 | """Add or remove legend to plot""" 142 | self.plot_settings[lit.LEGEND] = new_setting 143 | 144 | def error_bars(self, new_setting=True): 145 | """Add or remove error bars from plot""" 146 | self.plot_settings[lit.ERROR_BAR] = new_setting 147 | 148 | def residuals(self, new_setting=True): 149 | """Add or remove subplot to show residuals""" 150 | self.plot_settings[lit.RESIDUALS] = new_setting 151 | 152 | @property 153 | def title(self): 154 | """str: The title of this plot, which will appear on top of the figure""" 155 | return self._plot_info[lit.TITLE] 156 | 157 | @title.setter 158 | def title(self, new_title: str): 159 | if not isinstance(new_title, str): 160 | raise TypeError("The new title is not a string!") 161 | self._plot_info[lit.TITLE] = new_title 162 | 163 | @property 164 | def xname(self): 165 | """str: The name of the x data, which will appear as x label""" 166 | if self._plot_info[lit.XNAME]: 167 | return self._plot_info[lit.XNAME] 168 | xy_objects = (obj for obj in self._objects if isinstance(obj, XYObjectOnPlot)) 169 | return next((obj.xname for obj in xy_objects if obj.xname), "") 170 | 171 | @xname.setter 172 | def xname(self, name): 173 | if not isinstance(name, str): 174 | raise TypeError("Cannot set xname to \"{}\"".format(type(name).__name__)) 175 | self._plot_info[lit.XNAME] = name 176 | 177 | @property 178 | def yname(self): 179 | """str: The name of the y data, which will appear as y label""" 180 | if self._plot_info[lit.YNAME]: 181 | return self._plot_info[lit.YNAME] 182 | xy_objects = (obj for obj in self._objects if isinstance(obj, XYObjectOnPlot)) 183 | return next((obj.yname for obj in xy_objects if obj.yname), "") 184 | 185 | @yname.setter 186 | def yname(self, name): 187 | if not isinstance(name, str): 188 | raise TypeError("Cannot set yname to \"{}\"".format(type(name).__name__)) 189 | self._plot_info[lit.YNAME] = name 190 | 191 | @property 192 | def xunit(self): 193 | """str: The unit of the x data, which will appear on the x label""" 194 | if self._plot_info[lit.XUNIT]: 195 | return self._plot_info[lit.XUNIT] 196 | xy_objects = (obj for obj in self._objects if isinstance(obj, XYObjectOnPlot)) 197 | return next((obj.xunit for obj in xy_objects if obj.xunit), "") 198 | 199 | @xunit.setter 200 | def xunit(self, unit): 201 | if not isinstance(unit, str): 202 | raise TypeError("Cannot set xunit to \"{}\"".format(type(unit).__name__)) 203 | self._plot_info[lit.XUNIT] = unit 204 | 205 | @property 206 | def yunit(self): 207 | """str: The unit of the y data, which will appear on the y label""" 208 | if self._plot_info[lit.YUNIT]: 209 | return self._plot_info[lit.YUNIT] 210 | xy_objects = (obj for obj in self._objects if isinstance(obj, XYObjectOnPlot)) 211 | return next((obj.yunit for obj in xy_objects if obj.yunit), "") 212 | 213 | @yunit.setter 214 | def yunit(self, unit): 215 | if not isinstance(unit, str): 216 | raise TypeError("Cannot set yunit to \"{}\"".format(type(unit).__name__)) 217 | self._plot_info[lit.YUNIT] = unit 218 | 219 | @property 220 | def xlabel(self): 221 | """str: The xlabel of the plot""" 222 | return self.xname + ("[{}]".format(self.xunit) if self.xunit else "") 223 | 224 | @property 225 | def ylabel(self): 226 | """str: the ylabel of the plot""" 227 | return self.yname + ("[{}]".format(self.yunit) if self.yunit else "") 228 | 229 | @property 230 | def xrange(self): 231 | """tuple: The x-value domain of this plot""" 232 | if not self._xrange: 233 | objs = list(obj for obj in self._objects if isinstance(obj, ObjectWithRange)) 234 | low_bound = min(obj.xrange[0] for obj in objs if obj.xrange) 235 | high_bound = max(obj.xrange[1] for obj in objs if obj.xrange) 236 | return low_bound, high_bound 237 | return self._xrange 238 | 239 | @xrange.setter 240 | def xrange(self, new_range): 241 | utils.validate_xrange(new_range) 242 | self._xrange = new_range 243 | 244 | def __create_object_on_plot(self, *args, **kwargs) -> "ObjectOnPlot": 245 | """Factory method for creating ObjectOnPlot instances""" 246 | 247 | color = kwargs.pop("color", None) 248 | 249 | try: 250 | # The color of an XYFitResult will be dynamically determined at show time unless 251 | # explicitly specified by the user. No selecting from the color palette just yet. 252 | return XYFitResultOnPlot(*args, color=color, **kwargs) 253 | except IllegalArgumentError: 254 | pass 255 | 256 | try: 257 | color = color if color else self._color_palette.pop(0) 258 | return FunctionOnPlot(*args, color=color, **kwargs) 259 | except IllegalArgumentError: 260 | pass 261 | 262 | try: 263 | color = color if color else self._color_palette.pop(0) 264 | return XYDataSetOnPlot(*args, color=color, **kwargs) 265 | except IllegalArgumentError: 266 | pass 267 | 268 | # if everything has failed 269 | raise IllegalArgumentError("Invalid combination of arguments for plotting.") 270 | 271 | def __setup_figure_and_subplots(self): 272 | """Create the mpl figure and subplots""" 273 | 274 | has_residuals = self.plot_settings[lit.RESIDUALS] 275 | 276 | width, height = sts.get_settings().plot_dimensions 277 | 278 | if has_residuals: 279 | height = height * 1.5 280 | 281 | figure = plt.figure(figsize=(width, height), constrained_layout=True) 282 | 283 | if has_residuals: 284 | gs = figure.add_gridspec(3, 1) 285 | main_ax = figure.add_subplot(gs[:-1, :]) 286 | res_ax = figure.add_subplot(gs[-1:, :]) 287 | else: 288 | main_ax = figure.add_subplot() 289 | res_ax = None 290 | 291 | self.main_ax, self.res_ax = main_ax, res_ax 292 | 293 | 294 | def plot(*args, **kwargs) -> Plot: 295 | """Plots a dataset or a function 296 | 297 | Adds a dataset or a function to a Plot, and returns the Plot object. This is a wrapper 298 | around the matplotlib.pyplot.plot function, so it takes all the keyword arguments that is 299 | accepted by the pyplot.plot function, as well as the pyplot.errorbar function. 300 | 301 | By default, error bars are not displayed. If you want error bars, it can be turned on in 302 | the Plot object. 303 | 304 | Args: 305 | *args: The first arguments can be an XYDataSet object, two separate arrays for xdata 306 | and ydata, a callable function, or an XYFitResult object. The function also takes 307 | a string at the end of the list of arguments as the format string. 308 | 309 | Keyword Args: 310 | xdata: a list of data for x-values 311 | xerr: the uncertainties for the x-values 312 | ydata: a list of data for y-values 313 | yerr: the uncertainties for the y-values 314 | xrange (tuple): a tuple of two values specifying the x-range for the data to plot 315 | xname (str): the name of the x-values 316 | yname (str): the name of the y-values 317 | xunit (str): the unit of the x-values 318 | yunit (str): the unit of the y-values 319 | fmt (str): the format string for the object to be plotted (matplotlib style) 320 | color (str): the color for the object to be plotted 321 | label (str): the label for the object to be displayed in the legend 322 | **kwargs: additional keyword arguments that matplotlib.pyplot.plot supports 323 | 324 | 325 | See Also: 326 | :py:class:`~qexpy.data.XYDataSet`, 327 | `pyplot.plot `_, 328 | `pyplot.errorbar `_ 329 | 330 | """ 331 | 332 | plot_obj = __get_plot_obj() 333 | 334 | # invoke the instance method of the Plot to add objects to the plot 335 | plot_obj.plot(*args, **kwargs) 336 | 337 | return plot_obj 338 | 339 | 340 | def hist(*args, **kwargs) -> tuple: 341 | """Plots a histogram with a data set 342 | 343 | Args: 344 | *args: the ExperimentalValueArray or arguments that creates an ExperimentalValueArray 345 | 346 | See Also: 347 | `hist() `_ 348 | 349 | """ 350 | 351 | plot_obj = __get_plot_obj() 352 | 353 | # invoke the instance method of the Plot to add objects to the plot 354 | values, bin_edges = plot_obj.hist(*args, **kwargs) 355 | 356 | return values, bin_edges, plot_obj 357 | 358 | 359 | def show(plot_obj=None): 360 | """Draws the plot to output 361 | 362 | The QExPy plotting module keeps a buffer on the last plot being operated on. If no 363 | Plot instance is supplied to this function, the buffered plot will be shown. 364 | 365 | Args: 366 | plot_obj (Plot): the Plot instance to be shown. 367 | 368 | """ 369 | if not plot_obj: 370 | plot_obj = Plot.current_plot_buffer 371 | plot_obj.show() 372 | 373 | 374 | def savefig(filename, plot_obj=None, **kwargs): 375 | """Save the plot into a file 376 | 377 | The QExPy plotting module keeps a buffer on the last plot being operated on. If no 378 | Plot instance is supplied to this function, the buffered plot will be shown. 379 | 380 | Args: 381 | filename (string): name and format of the file (ex: myplot.pdf), 382 | plot_obj (Plot): the Plot instance to be shown. 383 | 384 | """ 385 | if not plot_obj: 386 | plot_obj = Plot.current_plot_buffer 387 | plot_obj.savefig(filename, **kwargs) 388 | 389 | 390 | def get_plot(): 391 | """Gets the current plot buffer""" 392 | return Plot.current_plot_buffer 393 | 394 | 395 | def new_plot(): 396 | """Clears the current plot buffer and start a new one""" 397 | Plot.current_plot_buffer = Plot() 398 | 399 | 400 | def __get_plot_obj(): 401 | """Helper function that gets the appropriate Plot instance to draw on""" 402 | 403 | # initialize buffer if not initialized 404 | Plot.current_plot_buffer = Plot() 405 | return Plot.current_plot_buffer 406 | -------------------------------------------------------------------------------- /qexpy/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """Package containing configurations for processing and displaying data""" 2 | 3 | from .settings import ErrorMethod, PrintStyle, UnitStyle, SigFigMode 4 | from .settings import get_settings, reset_default_configuration 5 | from .settings import set_sig_figs_for_value, set_sig_figs_for_error, set_error_method, \ 6 | set_print_style, set_unit_style, set_monte_carlo_sample_size, set_plot_dimensions 7 | -------------------------------------------------------------------------------- /qexpy/settings/literals.py: -------------------------------------------------------------------------------- 1 | """String literals for common settings 2 | 3 | It is highly recommended that developers use and update the constants in this file, which can 4 | be used when accessing common dictionary entries. This improves readability. 5 | 6 | """ 7 | 8 | # value types for error propagation 9 | DERIVATIVE = "derivative" 10 | MONTE_CARLO = "monte-carlo" 11 | MC_MEAN_AND_STD = "monte-carlo-mean-and-std" 12 | MC_MODE_AND_CONFIDENCE = "monte-carlo-mode_and_confidence" 13 | MC_CUSTOM = "monte-carlo-custom" 14 | MONTE_CARLO_STRATEGY = "mc-strategy" 15 | MONTE_CARLO_CONFIDENCE = "confidence" 16 | 17 | # data fields 18 | COVARIANCE = "covariance" 19 | CORRELATION = "correlation" 20 | VALUES = "values" 21 | 22 | # settings 23 | ERROR_METHOD = "error_method" 24 | PRINT_STYLE = "print_style" 25 | UNIT_STYLE = "unit_style" 26 | SIG_FIGS = "significant_figures" 27 | SIG_FIG_MODE = "mode" 28 | SIG_FIG_VALUE = "value" 29 | MONTE_CARLO_SAMPLE_SIZE = "monte_carlo_sample_size" 30 | 31 | LATEX = "latex" 32 | SCIENTIFIC = "scientific" 33 | FRACTION = "fraction" 34 | EXPONENTS = "exponents" 35 | SET_TO_VALUE = "set_to_value" 36 | SET_TO_ERROR = "set_to_error" 37 | 38 | # operators 39 | OPERATOR = "operator" 40 | OPERANDS = "operands" 41 | NEG = "neg" 42 | ADD = "add" 43 | SUB = "sub" 44 | MUL = "mul" 45 | DIV = "div" 46 | SQRT = "sqrt" 47 | SIN = "sin" 48 | COS = "cos" 49 | TAN = "tan" 50 | SEC = "sec" 51 | CSC = "csc" 52 | COT = "cot" 53 | POW = "pow" 54 | EXP = "exp" 55 | LOG = "log" 56 | LOG10 = "log10" 57 | LN = "ln" 58 | ASIN = "asin" 59 | ACOS = "acos" 60 | ATAN = "atan" 61 | 62 | # fitting 63 | LIN = "linear" 64 | QUAD = "quadratic" 65 | POLY = "polynomial" 66 | GAUSS = "gaussian" 67 | EXPO = "exponential" 68 | 69 | # plotting 70 | TITLE = "title" 71 | XNAME = "xname" 72 | YNAME = "yname" 73 | XUNIT = "xunit" 74 | YUNIT = "yunit" 75 | XRANGE = "xrange" 76 | 77 | LEGEND = "legend" 78 | ERROR_BAR = "error_bar" 79 | RESIDUALS = "residuals" 80 | PLOT_STYLE = "plot_style" 81 | PLOT_DIMENSIONS = "plot_dimensions" 82 | 83 | # miscellaneous 84 | DEFAULT = "default" 85 | AUTO = "auto" 86 | -------------------------------------------------------------------------------- /qexpy/settings/settings.py: -------------------------------------------------------------------------------- 1 | """Holds all global configurations and Enum types for common options""" 2 | 3 | import functools 4 | from enum import Enum 5 | from typing import Union 6 | 7 | from . import literals as lit 8 | 9 | 10 | class ErrorMethod(Enum): 11 | """Preferred method of error propagation""" 12 | DERIVATIVE = lit.DERIVATIVE 13 | MONTE_CARLO = lit.MONTE_CARLO 14 | AUTO = lit.AUTO 15 | 16 | 17 | class PrintStyle(Enum): 18 | """Preferred format for the string representation of values""" 19 | DEFAULT = lit.DEFAULT 20 | LATEX = lit.LATEX 21 | SCIENTIFIC = lit.SCIENTIFIC 22 | 23 | 24 | class UnitStyle(Enum): 25 | """Preferred format for the string representation of units""" 26 | FRACTION = lit.FRACTION 27 | EXPONENTS = lit.EXPONENTS 28 | 29 | 30 | class SigFigMode(Enum): 31 | """Preferred method to choose number of significant figures""" 32 | AUTOMATIC = lit.AUTO 33 | VALUE = lit.SET_TO_VALUE 34 | ERROR = lit.SET_TO_ERROR 35 | 36 | 37 | class Settings: 38 | """The settings object, implemented as a singleton""" 39 | 40 | __instance = None 41 | 42 | @staticmethod 43 | def get_instance(): 44 | """Gets the Settings singleton instance""" 45 | if not Settings.__instance: 46 | Settings.__instance = Settings() 47 | return Settings.__instance 48 | 49 | def __init__(self): 50 | self.__config = { 51 | lit.ERROR_METHOD: ErrorMethod.DERIVATIVE, 52 | lit.PRINT_STYLE: PrintStyle.DEFAULT, 53 | lit.UNIT_STYLE: UnitStyle.EXPONENTS, 54 | lit.SIG_FIGS: { 55 | lit.SIG_FIG_MODE: SigFigMode.AUTOMATIC, 56 | lit.SIG_FIG_VALUE: 1 57 | }, 58 | lit.MONTE_CARLO_SAMPLE_SIZE: 100000, 59 | lit.PLOT_DIMENSIONS: (6.4, 4.8) 60 | } 61 | 62 | @property 63 | def error_method(self) -> ErrorMethod: 64 | """ErrorMethod: The preferred error method for derived values 65 | 66 | There are three possible error methods, keep in mind that all three methods are used 67 | to calculate the values behind the scene. The options are found under q.ErrorMethod 68 | 69 | """ 70 | return self.__config[lit.ERROR_METHOD] 71 | 72 | @error_method.setter 73 | def error_method(self, new_method: Union[ErrorMethod, str]): 74 | if isinstance(new_method, ErrorMethod): 75 | self.__config[lit.ERROR_METHOD] = new_method 76 | elif new_method in [lit.MONTE_CARLO, lit.DERIVATIVE]: 77 | self.__config[lit.ERROR_METHOD] = ErrorMethod(new_method) 78 | else: 79 | raise ValueError("Invalid error method!") 80 | 81 | @property 82 | def print_style(self) -> PrintStyle: 83 | """PrintStyle: The preferred format to display a value with an uncertainty 84 | 85 | The three available formats are default, latex, and scientific. The options are found 86 | under q.PrintStyle 87 | 88 | """ 89 | return self.__config[lit.PRINT_STYLE] 90 | 91 | @print_style.setter 92 | def print_style(self, style: Union[PrintStyle, str]): 93 | if isinstance(style, PrintStyle): 94 | self.__config[lit.PRINT_STYLE] = style 95 | elif isinstance(style, str) and style in [lit.DEFAULT, lit.LATEX, lit.SCIENTIFIC]: 96 | self.__config[lit.PRINT_STYLE] = PrintStyle(style) 97 | else: 98 | raise ValueError("Invalid print style!") 99 | 100 | @property 101 | def unit_style(self) -> UnitStyle: 102 | """UnitStyle: The preferred format to display a unit string 103 | 104 | The supported unit styles are "fraction" and "exponents. Fraction style is the more 105 | intuitive way of showing units, looks like kg*m^2/s^2, whereas the exponent style 106 | shows the same unit as kg^1m^2s^-2, which is more accurate and less ambiguous. 107 | 108 | """ 109 | return self.__config[lit.UNIT_STYLE] 110 | 111 | @unit_style.setter 112 | def unit_style(self, style: Union[UnitStyle, str]): 113 | if isinstance(style, UnitStyle): 114 | self.__config[lit.UNIT_STYLE] = style 115 | elif isinstance(style, str) and style in [lit.FRACTION, lit.EXPONENTS]: 116 | self.__config[lit.UNIT_STYLE] = UnitStyle(style) 117 | else: 118 | raise ValueError("Invalid unit style!") 119 | 120 | @property 121 | def sig_fig_mode(self) -> SigFigMode: 122 | """SigFigMode: The standard for choosing number of significant figures 123 | 124 | Supported modes are VALUE and ERROR. When the mode is VALUE, the center value of the 125 | quantity will be displayed with the specified number of significant figures, and the 126 | uncertainty will be displayed to match the number of decimal places of the value, and 127 | vice versa for the ERROR mode. 128 | 129 | """ 130 | return self.__config[lit.SIG_FIGS][lit.SIG_FIG_MODE] 131 | 132 | @property 133 | def sig_fig_value(self) -> int: 134 | """int: The default number of significant figures""" 135 | return self.__config[lit.SIG_FIGS][lit.SIG_FIG_VALUE] 136 | 137 | @sig_fig_value.setter 138 | def sig_fig_value(self, new_value: int): 139 | if isinstance(new_value, int) and new_value > 0: 140 | self.__config[lit.SIG_FIGS][lit.SIG_FIG_VALUE] = new_value 141 | else: 142 | raise ValueError("The number of significant figures must be a positive integer") 143 | 144 | def set_sig_figs_for_value(self, new_sig_figs: int): 145 | """Sets the number of significant figures to show for all values""" 146 | self.sig_fig_value = new_sig_figs 147 | self.__config[lit.SIG_FIGS][lit.SIG_FIG_MODE] = SigFigMode.VALUE 148 | 149 | def set_sig_figs_for_error(self, new_sig_figs: int): 150 | """Sets the number of significant figures to show for uncertainties""" 151 | self.sig_fig_value = new_sig_figs 152 | self.__config[lit.SIG_FIGS][lit.SIG_FIG_MODE] = SigFigMode.ERROR 153 | 154 | @property 155 | def monte_carlo_sample_size(self) -> int: 156 | """int: The default sample size used in Monte Carlo error propagation""" 157 | return self.__config[lit.MONTE_CARLO_SAMPLE_SIZE] 158 | 159 | @monte_carlo_sample_size.setter 160 | def monte_carlo_sample_size(self, size: int): 161 | if isinstance(size, int) and size > 0: 162 | self.__config[lit.MONTE_CARLO_SAMPLE_SIZE] = size 163 | else: 164 | raise ValueError("The sample size has to be a positive integer") 165 | 166 | @property 167 | def plot_dimensions(self) -> (float, float): 168 | """The default dimensions of a plot in inches""" 169 | return self.__config[lit.PLOT_DIMENSIONS] 170 | 171 | @plot_dimensions.setter 172 | def plot_dimensions(self, new_dimensions: (float, float)): 173 | if not isinstance(new_dimensions, tuple) or len(new_dimensions) != 2: 174 | raise ValueError("The plot dimensions must be a tuple with two entries") 175 | if any(not isinstance(num, (int, float)) or num <= 0 for num in new_dimensions): 176 | raise ValueError("The dimensions of the plot must be numeric") 177 | self.__config[lit.PLOT_DIMENSIONS] = new_dimensions 178 | 179 | def reset(self): 180 | """Resets all configurations to their default values""" 181 | self.__config[lit.ERROR_METHOD] = ErrorMethod.DERIVATIVE 182 | self.__config[lit.PRINT_STYLE] = PrintStyle.DEFAULT 183 | self.__config[lit.SIG_FIGS][lit.SIG_FIG_MODE] = SigFigMode.AUTOMATIC 184 | self.__config[lit.SIG_FIGS][lit.SIG_FIG_VALUE] = 1 185 | self.__config[lit.UNIT_STYLE] = UnitStyle.EXPONENTS 186 | self.__config[lit.MONTE_CARLO_SAMPLE_SIZE] = 10000 187 | self.__config[lit.PLOT_DIMENSIONS] = (6.4, 4.8) 188 | 189 | 190 | def get_settings() -> Settings: 191 | """Gets the settings singleton instance""" 192 | return Settings.get_instance() 193 | 194 | 195 | def reset_default_configuration(): 196 | """Resets all configurations to their default values""" 197 | get_settings().reset() 198 | 199 | 200 | def set_error_method(new_method: Union[ErrorMethod, str]): 201 | """Sets the preferred error propagation method for values""" 202 | get_settings().error_method = new_method 203 | 204 | 205 | def set_print_style(new_style: Union[PrintStyle, str]): 206 | """Sets the format to display the value strings for ExperimentalValues""" 207 | get_settings().print_style = new_style 208 | 209 | 210 | def set_unit_style(new_style: Union[UnitStyle, str]): 211 | """Change the format for presenting units""" 212 | get_settings().unit_style = new_style 213 | 214 | 215 | def set_sig_figs_for_value(new_sig_figs: int): 216 | """Sets the number of significant figures to show for all values""" 217 | get_settings().set_sig_figs_for_value(new_sig_figs) 218 | 219 | 220 | def set_sig_figs_for_error(new_sig_figs: int): 221 | """Sets the number of significant figures to show for uncertainties""" 222 | get_settings().set_sig_figs_for_error(new_sig_figs) 223 | 224 | 225 | def set_monte_carlo_sample_size(size: int): 226 | """Sets the number of samples for a Monte Carlo simulation""" 227 | get_settings().monte_carlo_sample_size = size 228 | 229 | 230 | def set_plot_dimensions(new_dimensions: (float, float)): 231 | """Sets the default dimensions of a plot""" 232 | get_settings().plot_dimensions = new_dimensions 233 | 234 | 235 | def use_mc_sample_size(size: int): 236 | """Wrapper decorator that temporarily sets the monte carlo sample size""" 237 | 238 | def set_monte_carlo_sample_size_wrapper(func): 239 | """Inner wrapper decorator""" 240 | 241 | @functools.wraps(func) 242 | def inner_wrapper(*args): 243 | # preserve the original sample size and set the sample size to new value 244 | temp_size = get_settings().monte_carlo_sample_size 245 | set_monte_carlo_sample_size(size) 246 | 247 | # run the function 248 | result = func(*args) 249 | 250 | # restores the original sample size 251 | set_monte_carlo_sample_size(temp_size) 252 | 253 | # return function output 254 | return result 255 | 256 | return inner_wrapper 257 | 258 | return set_monte_carlo_sample_size_wrapper 259 | -------------------------------------------------------------------------------- /qexpy/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Package containing utility functions mostly for internal use""" 2 | 3 | from .utils import load_data_from_file 4 | from .utils import vectorize, check_operand_type, validate_xrange 5 | from .utils import numerical_derivative, calculate_covariance, cov2corr, \ 6 | find_mode_and_uncertainty 7 | from .exceptions import IllegalArgumentError, UndefinedActionError, UndefinedOperationError 8 | from .units import parse_unit_string, construct_unit_string, operate_with_units, \ 9 | define_unit, clear_unit_definitions 10 | from .printing import get_printer 11 | 12 | import sys 13 | import IPython 14 | 15 | if "ipykernel" in sys.modules: # pragma: no cover 16 | IPython.get_ipython().magic("matplotlib inline") 17 | -------------------------------------------------------------------------------- /qexpy/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | """Definitions for internal exceptions in QExPy""" 2 | 3 | 4 | class QExPyBaseError(Exception): 5 | """The base error type for QExPy""" 6 | 7 | 8 | class IllegalArgumentError(QExPyBaseError): 9 | """Exception for invalid arguments""" 10 | 11 | 12 | class UndefinedActionError(QExPyBaseError): 13 | """Exception for undefined system states or function calls""" 14 | 15 | 16 | class UndefinedOperationError(UndefinedActionError): 17 | """Exception for undefined arithmetic operations between values""" 18 | 19 | def __init__(self, op, got, expected): 20 | """Defines the standard format for the error message""" 21 | 22 | got_types = " and ".join("\'{}\'".format(type(x).__name__) for x in got) 23 | message = "\"{}\" is undefined with operands of type(s) {}. " \ 24 | "Expected: {}".format(op, got_types, expected) 25 | 26 | super().__init__(message) 27 | -------------------------------------------------------------------------------- /qexpy/utils/printing.py: -------------------------------------------------------------------------------- 1 | """Utility methods for generating the string representation of value-error pairs""" 2 | 3 | import math as m 4 | 5 | from typing import Callable 6 | from qexpy.settings import PrintStyle, SigFigMode 7 | 8 | import qexpy.settings as sts 9 | 10 | 11 | def get_printer(print_style: PrintStyle = None) -> Callable[[float, float], str]: 12 | """Gets the printer for the given print style 13 | 14 | If the print style is not specified, the global setting will be used. 15 | 16 | Args: 17 | print_style (PrintStyle): The desired print style. 18 | 19 | Returns: 20 | A printer function that takes two numbers as inputs for value and uncertainty and 21 | returns the string representation of the value-error pair 22 | 23 | """ 24 | if not print_style: 25 | print_style = sts.get_settings().print_style 26 | if print_style == PrintStyle.SCIENTIFIC: 27 | return __scientific_printer 28 | if print_style == PrintStyle.LATEX: 29 | return __latex_printer 30 | return __default_printer 31 | 32 | 33 | def __default_printer(value: float, error: float, latex=False) -> str: 34 | """Prints out the value and uncertainty in its default format""" 35 | 36 | pm = r"\pm" if latex else "+/-" 37 | 38 | if value == 0 and error == 0: 39 | return "0 {} 0".format(pm) 40 | if m.isinf(value): 41 | return "inf {} inf".format(pm) 42 | 43 | # Round the values based on significant digits 44 | rounded_value, rounded_error = __round_values_to_sig_figs(value, error) 45 | 46 | # Check if the number of decimals matches the requirement of significant figures 47 | decimals = __find_number_of_decimals(rounded_value, rounded_error) 48 | 49 | # Construct the string to return 50 | value_string = "{:.{num}f}".format(rounded_value, num=decimals) 51 | error_string = "{:.{num}f}".format(rounded_error, num=decimals) if error != 0 else "0" 52 | return "{} {} {}".format(value_string, pm, error_string) 53 | 54 | 55 | def __latex_printer(value: float, error: float) -> str: 56 | """Prints out the value and uncertainty in latex format""" 57 | return __scientific_printer(value, error, latex=True) 58 | 59 | 60 | def __scientific_printer(value: float, error: float, latex=False) -> str: 61 | """Prints out the value and uncertainty in scientific notation""" 62 | 63 | pm = r"\pm" if latex else "+/-" 64 | 65 | if value == 0 and error == 0: 66 | return "0 {} 0".format(pm) 67 | if m.isinf(value): 68 | return "inf {} inf".format(pm) 69 | 70 | # Find order of magnitude 71 | order = m.floor(m.log10(abs(value))) 72 | if order == 0: 73 | return __default_printer(value, error, latex) 74 | 75 | # Round the values based on significant digits 76 | rounded_value, rounded_error = __round_values_to_sig_figs(value, error) 77 | 78 | # Convert to scientific notation 79 | converted_value = rounded_value / (10 ** order) 80 | converted_error = rounded_error / (10 ** order) 81 | 82 | # Check if the number of decimals matches the requirement of significant figures 83 | decimals = __find_number_of_decimals(converted_value, converted_error) 84 | 85 | # Construct the string to return 86 | value_string = "{:.{num}f}".format(converted_value, num=decimals) 87 | error_string = "{:.{num}f}".format(converted_error, num=decimals) if error != 0 else "0" 88 | return "({} {} {}) * 10^{}".format(value_string, pm, error_string, order) 89 | 90 | 91 | def __round_values_to_sig_figs(value: float, error: float) -> (float, float): 92 | """Rounds the value and uncertainty based on sig-fig settings 93 | 94 | This method works by first finding the order of magnitude for the error, or the value, 95 | depending on the sig-fig settings, and calculates a value called back-off. For example, 96 | to round 12345 to 3 significant figures, log10(12345) would return 4, which is the order 97 | of magnitude of the number. The formula for the back-off is: 98 | 99 | back-off = order_of_magnitude - significant_digits + 1. 100 | 101 | In this case, the back-off would 4 - 3 + 1 = 2. With the back-off, we first divide 12345 102 | by 10^2, which results in 123.45, then round it to 123, before multiplying the back-off, 103 | which produces 12300 104 | 105 | Args: 106 | value (float): the value of the quantity to be rounded 107 | error (float): the uncertainty to be rounded 108 | 109 | Returns: 110 | the rounded results for this pair 111 | 112 | """ 113 | 114 | sig_fig_mode = sts.get_settings().sig_fig_mode 115 | sig_fig_value = sts.get_settings().sig_fig_value 116 | 117 | def is_valid(number): 118 | return not m.isinf(number) and not m.isnan(number) and number != 0 119 | 120 | # Check any of the inputs are invalid for the following calculations 121 | if sig_fig_mode in [SigFigMode.AUTOMATIC, SigFigMode.ERROR] and not is_valid(error): 122 | return value, error # do no rounding if the error is 0 or invalid 123 | if sig_fig_mode == SigFigMode.VALUE and not is_valid(value): 124 | return value, error # do no rounding if the value is 0 or invalid 125 | 126 | # First find the back-off value for rounding 127 | if sig_fig_mode in [SigFigMode.AUTOMATIC, SigFigMode.ERROR]: 128 | order_of_error = m.floor(m.log10(abs(error))) 129 | back_off = 10 ** (order_of_error - sig_fig_value + 1) 130 | else: 131 | order_of_value = m.floor(m.log10(abs(value))) 132 | back_off = 10 ** (order_of_value - sig_fig_value + 1) 133 | 134 | # Then round the value and error to the same digit 135 | rounded_error = round(error / back_off) * back_off 136 | rounded_value = round(value / back_off) * back_off 137 | 138 | # Return the two rounded values 139 | return rounded_value, rounded_error 140 | 141 | 142 | def __find_number_of_decimals(value: float, error: float) -> int: 143 | """Finds the correct number of decimal places to show for a value-error pair 144 | 145 | This method checks the settings for significant figures and tweaks the already rounded 146 | value and error to having the correct number of significant figures. For example, if the 147 | value of a variable is 5.001, and 3 significant figures is requested. After rounding, the 148 | value would become 5. However, if we want it to be represented as 5.00, we need to find 149 | the proper number of digits after the decimal. 150 | 151 | The implementation is similar to that of the rounding algorithm described in the method 152 | above. The key is to start counting significant figures from the most significant digit, 153 | which is calculated by finding the order of magnitude of the value. 154 | 155 | See Also: 156 | __round_values_to_sig_figs 157 | 158 | """ 159 | 160 | sig_fig_mode = sts.get_settings().sig_fig_mode 161 | sig_fig_value = sts.get_settings().sig_fig_value 162 | 163 | def is_valid(number): 164 | return not m.isinf(number) and not m.isnan(number) and number != 0 165 | 166 | # Check if the current number of significant figures satisfy the settings 167 | if sig_fig_mode in [SigFigMode.AUTOMATIC, SigFigMode.ERROR]: 168 | order = m.floor(m.log10(abs(error))) if is_valid(error) else m.floor(m.log10(abs(value))) 169 | else: 170 | order = m.floor(m.log10(abs(value))) if is_valid(value) else m.floor(m.log10(abs(error))) 171 | 172 | number_of_decimals = - order + sig_fig_value - 1 173 | return number_of_decimals if number_of_decimals > 0 else 0 174 | -------------------------------------------------------------------------------- /qexpy/utils/units.py: -------------------------------------------------------------------------------- 1 | """Internal module used for unit parsing and propagation""" 2 | 3 | import re 4 | import warnings 5 | 6 | from typing import Dict, List, Union 7 | from collections import namedtuple, OrderedDict 8 | from qexpy.settings import UnitStyle 9 | from copy import deepcopy 10 | from fractions import Fraction 11 | 12 | import qexpy.settings as sts 13 | import qexpy.settings.literals as lit 14 | 15 | from .exceptions import IllegalArgumentError 16 | 17 | # The standard character used in a dot multiply expression 18 | DOT_STRING = "⋅" 19 | 20 | # A sub-tree in a binary expression tree representing a unit expression. The "operator" is 21 | # the root node of the sub-tree, and the "left" and "right" points to the two branches. The 22 | # leaf nodes of a unit expression tree are either unit strings or their powers. 23 | Expression = namedtuple("Expression", "operator, left, right") 24 | 25 | 26 | # A dictionary to keep track of equivalent units 27 | UNIT_DEFINITIONS = {} 28 | 29 | 30 | def clear_unit_definitions(): 31 | """Delete all unit definitions""" 32 | 33 | global UNIT_DEFINITIONS # pylint:disable=global-statement 34 | 35 | UNIT_DEFINITIONS = {} 36 | 37 | 38 | def define_unit(name: str, unit: str): 39 | """Assign a name to a unit expression 40 | 41 | This function can be used to manually define N (Newton) as kg*m/s^2, which will make 42 | unit operations easier. 43 | 44 | Args: 45 | name: the name of the unit expression 46 | unit: the unit expression 47 | 48 | Examples: 49 | >>> import qexpy as q 50 | >>> q.define_unit("N", "kg*m/s^2") 51 | 52 | """ 53 | 54 | global UNIT_DEFINITIONS # pylint:disable=global-statement 55 | 56 | if not re.match(r"^[\w]+$", name): 57 | raise IllegalArgumentError("The name of the new unit can only contain letters") 58 | 59 | UNIT_DEFINITIONS[name] = parse_unit_string(unit) 60 | 61 | 62 | def parse_unit_string(unit_string: str) -> Dict[str, int]: 63 | """Decodes the string representation of a set of units 64 | 65 | This function parses the unit string into a binary expression tree, evaluate the tree to 66 | find all units present in the string and their powers, which is then stored in a Python 67 | dictionary object. 68 | 69 | The units are parsed to the following rules: 70 | 1. Expressions enclosed in brackets are evaluated first 71 | 2. A unit with its power (e.g. "m^2") are always evaluated together 72 | 3. Expressions connected with implicit multiplication are evaluated together 73 | 74 | For example, "kg*m^2/s^2A^2" would be decoded to: {"kg": 1, "m": 2, "s": -2, "A": -2} 75 | 76 | Args: 77 | unit_string (str): The string to be parsed 78 | 79 | Returns: 80 | A dictionary object that stores the power of each unit in the expression 81 | 82 | """ 83 | tokens = __parse_unit_string_to_list(unit_string) 84 | ast = __construct_expression_tree_with_list(tokens) 85 | return __evaluate_unit_tree(ast) 86 | 87 | 88 | def construct_unit_string(units: Dict[str, int]) -> str: 89 | """Constructs the string representation of a set of units 90 | 91 | Units can be displayed in two different formats: Fraction and Exponents. The function 92 | retrieves the global settings for unit styles and construct the string accordingly. 93 | 94 | Args: 95 | units (dict): A dictionary object representing a set of units 96 | 97 | Returns: 98 | The string representation of the units 99 | 100 | """ 101 | 102 | # pack full pre-defined compound units if applicable 103 | for unit, expression in UNIT_DEFINITIONS.items(): 104 | exp = __try_pack(units, expression) 105 | if exp: 106 | units = {unit: exp} 107 | break 108 | 109 | unit_string = "" 110 | if sts.get_settings().unit_style == UnitStyle.FRACTION: 111 | unit_string = __construct_unit_string_as_fraction(units) 112 | if sts.get_settings().unit_style == UnitStyle.EXPONENTS: 113 | unit_string = __construct_unit_string_with_exponents(units) 114 | return unit_string 115 | 116 | 117 | def operate_with_units(operator, *operands): 118 | """perform an operation with two sets of units""" 119 | 120 | # first unpack any pre-defined units 121 | opr_unpacked = [__unpack_unit(operand) for operand in operands] 122 | 123 | # obtain the results 124 | result = UNIT_OPERATIONS[operator](*opr_unpacked) if operator in UNIT_OPERATIONS else {} 125 | 126 | # filter for non-zero values 127 | result = OrderedDict([(unit, count) for unit, count in result.items() if count != 0]) 128 | 129 | # pack full pre-defined compound units 130 | for unit, expression in UNIT_DEFINITIONS.items(): 131 | exp = __try_pack(result, expression) 132 | if exp: 133 | return {unit: exp} 134 | 135 | return result 136 | 137 | 138 | def __parse_unit_string_to_list(unit_string: str) -> List[Union[str, List]]: 139 | """Parse a unit string into a list of tokens 140 | 141 | A token can be a single unit, an operator such as "*" or "/" or "^", a number indicating 142 | the power of a unit, or a list of tokens grouped together. For example, kg*m/s^2A^2 would 143 | be parsed into: ["kg", "*", "m", "/", [["s", "^", "2"], "*", ["A", "^", "2"]]] 144 | 145 | """ 146 | 147 | unit_string = unit_string.replace("⋅", "*") # replace dots with multiplication sign 148 | 149 | raw_tokens_list = [] # The raw list of tokens 150 | tokens_list = [] # The final list of tokens 151 | 152 | token_pattern = re.compile(r"[a-zA-Z]+(\^-?[0-9]+)?|/|\*|\(.*?\)") 153 | bracket_enclosed_expression_pattern = re.compile(r"\(.*?\)") 154 | unit_with_exponent_pattern = re.compile(r"[a-zA-Z]+\^-?[0-9]+") 155 | operator_pattern = re.compile(r"[/*]") 156 | 157 | # Check if the input only consists of valid token strings 158 | if not re.fullmatch(r"({})+".format(token_pattern.pattern), unit_string): 159 | raise ValueError("\"{}\" is not a valid unit".format(unit_string)) 160 | 161 | # For every token found, process it and append it to the list 162 | for result in token_pattern.finditer(unit_string): 163 | token = result.group() 164 | if bracket_enclosed_expression_pattern.fullmatch(token): 165 | # If the token is a bracket enclosed expression, recursively parse the content of 166 | # that bracket and append it to the tokens list as a list 167 | raw_tokens_list.append(__parse_unit_string_to_list(token[1:-1])) 168 | elif unit_with_exponent_pattern.fullmatch(token): 169 | # Group a unit with exponent together and append to the list as a whole 170 | unit_and_exponent = token.split("^") 171 | raw_tokens_list.append([unit_and_exponent[0], "^", unit_and_exponent[1]]) 172 | else: 173 | raw_tokens_list.append(token) 174 | 175 | # At this stage, except for when an explicit bracket is present, no grouping of tokens 176 | # has occurred yet. The following code checks for expressions connected with implicit 177 | # multiplication, and groups them together (also adding a multiplication operator). The 178 | # following flag keeps track of if there is an operator present between the current token 179 | # and the last expression being processed, if not, assume implicit multiplication. 180 | preceding_operator_exists = True 181 | 182 | for token in raw_tokens_list: 183 | if preceding_operator_exists: 184 | tokens_list.append(token) 185 | preceding_operator_exists = False 186 | elif isinstance(token, str) and operator_pattern.fullmatch(token): 187 | tokens_list.append(token) 188 | preceding_operator_exists = True 189 | else: 190 | # When there is no preceding operator, and the current token is not an operator, 191 | # add multiplication sign, and group this item with the previous one. 192 | last_token = tokens_list.pop() 193 | tokens_list.append([last_token, "*", token]) 194 | preceding_operator_exists = False 195 | 196 | return tokens_list 197 | 198 | 199 | def __construct_expression_tree_with_list(tokens: List[Union[str, List]]) -> Expression: 200 | """Build a binary expression tree with a list of tokens 201 | 202 | The algorithm to construct the tree is called recursive descent, which made use of two 203 | stacks. The operator stack and the operand stack. For each new token, if the token is an 204 | operator, it is compared with the current top of the operator stack. The operator stack 205 | is maintained so that the top of the stack has higher priority in order of operations 206 | compared to the rest of the stack. If the current top has higher priority compared to the 207 | operator being processed, it is popped from the stack, used to build a sub-tree with the 208 | top two operands in the operand stack, and pushed into the operand stack. 209 | 210 | For details regarding this algorithm, see the reference below. 211 | 212 | Reference: 213 | Parsing Expressions by Recursive Descent - Theodore Norvell (C) 1999 214 | https://www.engr.mun.ca/~theo/Misc/exp_parsing.htm 215 | 216 | Args: 217 | tokens (list): The list of tokens to process. 218 | 219 | Returns: 220 | The expression tree representing the set of units. For more details regarding the 221 | structure of the tree, see top of this file where the Expression type is defined. 222 | 223 | """ 224 | 225 | # Initialize the two stacks 226 | operand_stack = [] # type: List[Union[Expression, str]] 227 | operator_stack = ["base"] # type: List[str] 228 | 229 | # Define the order of operations 230 | precedence = { 231 | "base": 0, 232 | "*": 1, 233 | "/": 1, 234 | "^": 2 235 | } 236 | 237 | def __construct_sub_tree_and_push_to_operand_stack(): 238 | right = operand_stack.pop() 239 | left = operand_stack.pop() 240 | operator = operator_stack.pop() 241 | operand_stack.append(Expression(operator, left, right)) 242 | 243 | # Push all tokens into the two stacks, make sub-trees if necessary 244 | for token in tokens: 245 | top_of_operators = operator_stack[-1] 246 | if isinstance(token, list): 247 | # Recursively make sub-tree with grouped expressions 248 | operand_stack.append(__construct_expression_tree_with_list(token)) 249 | elif token in precedence and precedence[token] > precedence[top_of_operators]: 250 | operator_stack.append(token) # Push the higher priority operator on top 251 | elif token in precedence and precedence[token] <= precedence[top_of_operators]: 252 | # If an operator with lower precedence is being processed, make a sub-tree 253 | # with the current top of the operator stack and push it to the operands. 254 | __construct_sub_tree_and_push_to_operand_stack() 255 | operator_stack.append(token) # This operator becomes the new top 256 | else: 257 | operand_stack.append(token) 258 | 259 | # Create the final tree from all the tokens and sub-trees left in the stacks 260 | while len(operator_stack) > 1: 261 | __construct_sub_tree_and_push_to_operand_stack() 262 | 263 | return operand_stack[0] if operand_stack else Expression("", "", "") 264 | 265 | 266 | def __evaluate_unit_tree(tree: Expression) -> Dict[str, int]: 267 | """Construct a unit dictionary object from an expression tree 268 | 269 | Args: 270 | tree (Expression): the expression tree to be evaluated 271 | 272 | Returns: 273 | All units in the tree and their powers stored in a dictionary object 274 | 275 | """ 276 | units = OrderedDict() 277 | if isinstance(tree, Expression) and tree.operator == "^": 278 | # When a unit with an exponent is found, add it to the dictionary object 279 | units[tree.left] = int(tree.right) 280 | elif isinstance(tree, Expression) and tree.operator in ["*", "/"]: 281 | for unit, exponent in __evaluate_unit_tree(tree.left).items(): 282 | units[unit] = exponent 283 | for unit, exponent in __evaluate_unit_tree(tree.right).items(): 284 | start_exponent_from = units[unit] if unit in units else 0 285 | plus_or_minus = 1 if tree.operator == "*" else -1 286 | units[unit] = start_exponent_from + plus_or_minus * exponent 287 | else: # just a string then count it 288 | units[tree] = 1 289 | return units 290 | 291 | 292 | def __construct_unit_string_as_fraction(units: Dict[str, int]) -> str: 293 | """Construct a unit string in the fraction format""" 294 | 295 | numerator_units = ["{}{}".format( 296 | unit, __power_num2str(power)) for unit, power in units.items() if power > 0] 297 | denominator_units = ["{}{}".format( 298 | unit, __power_num2str(-power)) for unit, power in units.items() if power < 0] 299 | 300 | numerator_string = DOT_STRING.join(numerator_units) if numerator_units else "1" 301 | denominator_string = DOT_STRING.join(denominator_units) 302 | 303 | if not denominator_units: 304 | return numerator_string if numerator_units else "" 305 | if len(denominator_units) > 1: 306 | # For multiple units in the denominator, use brackets to avoid ambiguity 307 | return "{}/({})".format(numerator_string, denominator_string) 308 | 309 | return "{}/{}".format(numerator_string, denominator_string) 310 | 311 | 312 | def __construct_unit_string_with_exponents(units: Dict[str, int]) -> str: 313 | """Construct a unit string in the exponent format""" 314 | unit_strings = ["{}{}".format( 315 | unit, __power_num2str(power)) for unit, power in units.items()] 316 | return DOT_STRING.join(unit_strings) 317 | 318 | 319 | def __unpack_unit(unit, count=1): 320 | """unpacks any pre-defined units""" 321 | 322 | global UNIT_DEFINITIONS # pylint: disable=global-statement 323 | 324 | if isinstance(unit, str) and unit not in UNIT_DEFINITIONS: 325 | return OrderedDict({unit: count}) 326 | 327 | if isinstance(unit, str) and unit in UNIT_DEFINITIONS: 328 | return __unpack_unit(UNIT_DEFINITIONS[unit], count) 329 | 330 | result = OrderedDict() 331 | 332 | for name, exp in unit.items(): 333 | unpacked = __unpack_unit(name, exp) 334 | for tok, val in unpacked.items(): 335 | __update_unit_exponent_count_in_dict(result, tok, val) 336 | 337 | return result 338 | 339 | 340 | def __try_pack(unit, pre_defined): 341 | """try packing unit into some power of a predefined compound unit""" 342 | 343 | exponent = 0 344 | for name, exp in unit.items(): 345 | pre_exp = pre_defined.get(name, 0) 346 | if not pre_exp: 347 | return 0 # unit non-existing in predefined units 348 | if exponent and exponent != exp / pre_exp: 349 | return 0 # exponent different from previous 350 | if not exponent: 351 | exponent = exp / pre_exp 352 | for name, exp in pre_defined.items(): 353 | if not unit.get(name, 0): 354 | return 0 355 | return exponent 356 | 357 | 358 | def __power_num2str(power) -> str: 359 | """Construct a string for the power of a unit""" 360 | 361 | fraction = Fraction(power).limit_denominator(10) 362 | if fraction.numerator == 1 and fraction.denominator == 1: 363 | return "" # do not print power of 1 as it's implied 364 | if fraction.denominator == 1: 365 | return "^{}".format(str(fraction.numerator)) 366 | return "^({})".format(str(fraction)) 367 | 368 | 369 | def __neg(units): 370 | return deepcopy(units) 371 | 372 | 373 | def __add_and_sub(units_var1, units_var2): 374 | if units_var1 and units_var2 and units_var1 != units_var2: 375 | warnings.warn("You're trying to add/subtract two values with mismatching units.") 376 | return OrderedDict() 377 | if not units_var1: # If any of the two units are empty, use the other one 378 | return deepcopy(units_var2) 379 | return deepcopy(units_var1) 380 | 381 | 382 | def __mul(units_var1, units_var2): 383 | units = OrderedDict() 384 | for unit, exponent in units_var1.items(): 385 | __update_unit_exponent_count_in_dict(units, unit, exponent) 386 | for unit, exponent in units_var2.items(): 387 | __update_unit_exponent_count_in_dict(units, unit, exponent) 388 | return units 389 | 390 | 391 | def __div(units_var1, units_var2): 392 | units = OrderedDict() 393 | for unit, exponent in units_var1.items(): 394 | __update_unit_exponent_count_in_dict(units, unit, exponent) 395 | for unit, exponent in units_var2.items(): 396 | __update_unit_exponent_count_in_dict(units, unit, -exponent) 397 | return units 398 | 399 | 400 | def __sqrt(units): 401 | new_units = OrderedDict() 402 | for unit, exponent in units.items(): 403 | new_units[unit] = exponent / 2 404 | return new_units 405 | 406 | 407 | def __update_unit_exponent_count_in_dict(unit_dict, unit_string, change): 408 | current_count = 0 if unit_string not in unit_dict else unit_dict[unit_string] 409 | unit_dict[unit_string] = current_count + change 410 | 411 | 412 | UNIT_OPERATIONS = { 413 | lit.NEG: __neg, 414 | lit.ADD: __add_and_sub, 415 | lit.SUB: __add_and_sub, 416 | lit.MUL: __mul, 417 | lit.DIV: __div, 418 | lit.SQRT: __sqrt 419 | } 420 | -------------------------------------------------------------------------------- /qexpy/utils/utils.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous utility functions""" 2 | 3 | import functools 4 | import csv 5 | 6 | import numpy as np 7 | 8 | from typing import Callable 9 | from numbers import Real 10 | from .exceptions import UndefinedOperationError 11 | 12 | 13 | def check_operand_type(operation): 14 | """wrapper decorator for undefined operation error reporting""" 15 | 16 | def check_operand_type_wrapper(func): 17 | 18 | @functools.wraps(func) 19 | def operation_wrapper(*args): 20 | try: 21 | return func(*args) 22 | except TypeError: 23 | raise UndefinedOperationError(operation, got=args, expected="real numbers") 24 | 25 | return operation_wrapper 26 | 27 | return check_operand_type_wrapper 28 | 29 | 30 | def vectorize(func): 31 | """vectorize a function if inputs are arrays""" 32 | 33 | @functools.wraps(func) 34 | def wrapper_vectorize(*args): 35 | if any(isinstance(arg, np.ndarray) for arg in args): 36 | return np.vectorize(func)(*args) 37 | if any(isinstance(arg, list) for arg in args): 38 | return np.vectorize(func)(*args).tolist() 39 | return func(*args) 40 | 41 | return wrapper_vectorize 42 | 43 | 44 | def validate_xrange(xrange): 45 | """validates that an xrange is legal""" 46 | 47 | if not isinstance(xrange, (tuple, list)) or len(xrange) != 2: 48 | raise TypeError("The \"xrange\" should be a list or tuple of length 2") 49 | 50 | if any(not isinstance(value, Real) for value in xrange): 51 | raise TypeError("The \"xrange\" must be real numbers") 52 | 53 | if xrange[0] > xrange[1]: 54 | raise ValueError("The low bound of xrange is higher than the high bound") 55 | 56 | return True 57 | 58 | 59 | @vectorize 60 | def numerical_derivative(function: Callable, x0: Real, dx=1e-5): 61 | """Calculates the numerical derivative of a function with respect to x at x0""" 62 | return (function(x0 + dx) - function(x0 - dx)) / (2 * dx) 63 | 64 | 65 | def calculate_covariance(arr_x, arr_y): 66 | """Calculates the covariance of two arrays""" 67 | if len(arr_x) != len(arr_y): 68 | raise ValueError("Cannot calculate covariance for arrays of different lengths.") 69 | return 1 / (len(arr_x) - 1) * sum( 70 | ((x - np.mean(arr_x)) * (y - np.mean(arr_y)) for x, y in zip(arr_x, arr_y))) 71 | 72 | 73 | def cov2corr(pcov: np.ndarray) -> np.ndarray: 74 | """Calculate a correlation matrix from a covariance matrix""" 75 | std = np.sqrt(np.diag(pcov)) 76 | return pcov / np.outer(std, std) 77 | 78 | 79 | def find_mode_and_uncertainty(n, bins, confidence) -> (float, float): 80 | """Find the mode and uncertainty with a confidence of a histogram distribution""" 81 | number_of_samples = sum(n) 82 | max_idx = n.argmax() 83 | value = (bins[max_idx] + bins[max_idx + 1]) / 2 84 | count = n[max_idx] 85 | low_idx, high_idx = max_idx, max_idx 86 | while count < confidence * number_of_samples: 87 | low_idx -= 1 88 | high_idx += 1 89 | count += n[low_idx] + n[high_idx] 90 | error = (bins[high_idx] + bins[high_idx + 1]) / 2 - value 91 | return value, error 92 | 93 | 94 | def load_data_from_file(filepath: str, delimiter=",") -> np.ndarray: 95 | """Reads arrays of data from a file 96 | 97 | The file should be structured like a csv file. The delimiter can be replaced with other 98 | characters, but the default is comma. The function returns an array of arrays, one for 99 | each column in the table of numbers. 100 | 101 | Args: 102 | filepath (str): The name of the file to read from 103 | delimiter (str): The delimiter that separates each row 104 | 105 | Returns: 106 | A 2-dimensional np.ndarray where each array is a column in the file 107 | 108 | """ 109 | with open(filepath, newline='') as openfile: 110 | reader = csv.reader(openfile, delimiter=delimiter) 111 | # read file into array of rows 112 | rows_of_data = list([float(entry) for entry in row] for row in reader) 113 | # transpose data into array of columns 114 | result = np.transpose(np.array(rows_of_data, dtype=float)) 115 | return result 116 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | matplotlib 3 | scipy 4 | IPython 5 | pylint 6 | pytest 7 | coverage 8 | jupyterlab 9 | sphinx 10 | nbsphinx 11 | nbsphinx_link 12 | sphinx_autodoc_typehints 13 | sphinx_rtd_theme 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='qexpy', 5 | packages=find_packages(), 6 | version='2.0.2', 7 | description='''Package to handle error analysis and data plotting aimed at undergraduate physics.''', 8 | long_description='''QExPy (Queen’s Experimental Physics) is a Python 3 package designed to facilitate data analysis in undergraduate physics laboratories. The package contains a module to easily propagate errors in uncertainty calculations, and a module that provides an intuitive interface to plot and fit data. The package is designed to be efficient, correct, and to allow for a pedagogic introduction to error analysis. The package is extensively tested in the Jupyter Notebook environment to allow high quality reports to be generated directly from a browser.''', 9 | author='Astral Cai, Connor Kapahi, Prof. Ryan Martin', 10 | author_email='astralcai@gmail.com, ryan.martin@queensu.ca', 11 | license='GNU GPL v3', 12 | url='https://qexpy.readthedocs.io/en/latest/index.html', 13 | project_urls={ 14 | "Bug Tracker": 'https://github.com/Queens-Physics/qexpy/issues', 15 | "Documentation": 'https://qexpy.readthedocs.io/en/latest/index.html', 16 | "Source Code": 'https://github.com/Queens-Physics/qexpy', 17 | }, 18 | keywords=['physics', 'laboratories', 'labs', 'undergraduate', 'data analysis', 'uncertainties', 'plotting', 19 | 'error analysis', 'error propagation', 'uncertainty propagation'], 20 | classifiers=[ 21 | 'Development Status :: 3 - Alpha', 22 | 'Intended Audience :: Science/Research', 23 | 'Topic :: Scientific/Engineering :: Physics', 24 | 'License :: OSI Approved :: GNU General Public License (GPL)', 25 | 'Programming Language :: Python', 26 | ], 27 | install_requires=['numpy', 'matplotlib', 'scipy', 'IPython'], 28 | extras_require={ 29 | 'dev': ['pylint', 'pytest'], 30 | 'doc': ['jupyterlab', 'sphinx', 'nbsphinx', 'nbsphinx_link', 'sphinx_autodoc_typehints', 'sphinx_rtd_theme'] 31 | } 32 | ) 33 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | omit = 5 | */docs/* 6 | */plotting/* 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | def __repr__ 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /tests/resources/data_for_test_load_data.csv: -------------------------------------------------------------------------------- 1 | 1,0.5,1.00,0.02 2 | 2,0.5,2.30,0.02 3 | 3,0.5,3.48,0.02 4 | 4,0.5,4.60,0.02 5 | 5,0.5,5.70,0.02 6 | 6,0.5,6.78,0.02 7 | 7,0.5,7.85,0.02 8 | 8,0.5,8.90,0.02 9 | 9,0.5,9.95,0.02 10 | 10,0.5,11.00,0.02 11 | 11,0.5,12.04,0.02 12 | 12,0.5,13.08,0.02 13 | 13,0.5,14.11,0.02 14 | 14,0.5,15.15,0.02 15 | 15,0.5,16.18,0.02 16 | 16,0.5,17.20,0.02 17 | 17,0.5,18.23,0.02 18 | 18,0.5,19.26,0.02 19 | 19,0.5,20.28,0.02 20 | 20,0.5,21.30,0.02 21 | 21,0.5,22.32,0.02 22 | 22,0.5,23.34,0.02 23 | 23,0.5,24.36,0.02 24 | 24,0.5,25.38,0.02 25 | 25,0.5,26.40,0.02 26 | 26,0.5,27.41,0.02 27 | 27,0.5,28.43,0.02 28 | 28,0.5,29.45,0.02 29 | 29,0.5,30.46,0.02 30 | 30,0.5,31.48,0.02 31 | -------------------------------------------------------------------------------- /tests/test_datasets.py: -------------------------------------------------------------------------------- 1 | """Unit tests for taking arrays of measurements""" 2 | 3 | import pytest 4 | import qexpy as q 5 | import numpy as np 6 | 7 | from qexpy.utils.exceptions import IllegalArgumentError 8 | 9 | from qexpy.data.datasets import ExperimentalValueArray 10 | 11 | 12 | class TestExperimentalValueArray: 13 | """tests for the ExperimentalValueArray class""" 14 | 15 | def test_record_measurement_array(self): 16 | """tests for recording a measurement array in different ways""" 17 | 18 | a = q.MeasurementArray([1, 2, 3, 4, 5]) 19 | assert isinstance(a, ExperimentalValueArray) 20 | assert all(a.values == [1, 2, 3, 4, 5]) 21 | assert all(a.errors == [0, 0, 0, 0, 0]) 22 | assert str(a) == "[ 1 +/- 0, 2 +/- 0, 3 +/- 0, 4 +/- 0, 5 +/- 0 ]" 23 | 24 | with pytest.raises(TypeError): 25 | q.MeasurementArray(1) 26 | 27 | b = q.MeasurementArray([1, 2, 3, 4, 5], 0.5) 28 | assert all(b.errors == [0.5, 0.5, 0.5, 0.5, 0.5]) 29 | c = q.MeasurementArray([1, 2, 3, 4, 5], relative_error=0.1) 30 | assert c.errors == pytest.approx([0.1, 0.2, 0.3, 0.4, 0.5]) 31 | d = q.MeasurementArray([1, 2, 3, 4, 5], [0.1, 0.2, 0.3, 0.4, 0.5]) 32 | assert all(d.errors == [0.1, 0.2, 0.3, 0.4, 0.5]) 33 | e = q.MeasurementArray([1, 2, 3, 4, 5], relative_error=[0.1, 0.2, 0.3, 0.4, 0.5]) 34 | assert e.errors == pytest.approx([0.1, 0.4, 0.9, 1.6, 2.5]) 35 | 36 | with pytest.raises(ValueError): 37 | q.MeasurementArray([1, 2, 3, 4, 5], [0.1, 0.2, 0.3, 0.4]) 38 | with pytest.raises(ValueError): 39 | q.MeasurementArray([1, 2, 3, 4, 5], relative_error=[0.1, 0.2, 0.3, 0.4]) 40 | with pytest.raises(TypeError): 41 | q.MeasurementArray([1, 2, 3, 4, 5], '1') 42 | with pytest.raises(TypeError): 43 | q.MeasurementArray([1, 2, 3, 4, 5], [0.1, 0.2, 0.3, 0.4, '0.5']) 44 | with pytest.raises(TypeError): 45 | q.MeasurementArray([1, 2, 3, 4, '5']) 46 | with pytest.raises(ValueError): 47 | q.MeasurementArray([1, 2, 3, 4, 5], -0.5) 48 | with pytest.raises(ValueError): 49 | q.MeasurementArray([1, 2, 3, 4, 5], [0.5, 0.5, 0.5, 0.5, -0.5]) 50 | 51 | f = q.MeasurementArray(data=[1, 2, 3, 4], error=0.5, name="test", unit="m") 52 | assert f.name == "test" 53 | assert f.unit == "m" 54 | assert str(f) == "test = [ 1.0 +/- 0.5, 2.0 +/- 0.5, 3.0 +/- 0.5, 4.0 +/- 0.5 ] (m)" 55 | assert str(f[0]) == "test_0 = 1.0 +/- 0.5 [m]" 56 | assert str(f[-1]) == "test_3 = 4.0 +/- 0.5 [m]" 57 | 58 | g = q.MeasurementArray( 59 | [q.Measurement(5, 0.5), q.Measurement(10, 0.5)], name="test", unit="m") 60 | assert str(g[-1]) == "test_1 = 10.0 +/- 0.5 [m]" 61 | 62 | h = q.MeasurementArray([q.Measurement(5, 0.5), q.Measurement(10, 0.5)], error=0.1) 63 | assert str(h[-1]) == "10.0 +/- 0.1" 64 | 65 | def test_manipulate_measurement_array(self): 66 | """tests for manipulating a measurement array""" 67 | 68 | a = q.MeasurementArray([1, 2, 3, 4], 0.5, name="test", unit="m") 69 | a = a.append(q.Measurement(5, 0.5)) 70 | assert str(a[-1]) == "test_4 = 5.0 +/- 0.5 [m]" 71 | a = a.insert(1, (1.5, 0.5)) 72 | assert str(a[1]) == "test_1 = 1.5 +/- 0.5 [m]" 73 | assert str(a[-1]) == "test_5 = 5.0 +/- 0.5 [m]" 74 | a = a.delete(1) 75 | assert str(a[1]) == "test_1 = 2.0 +/- 0.5 [m]" 76 | 77 | with pytest.raises(TypeError): 78 | a.name = 1 79 | with pytest.raises(TypeError) as e: 80 | a.unit = 1 81 | assert str(e.value) == "Cannot set unit to \"int\"!" 82 | 83 | a.name = "speed" 84 | a.unit = "m/s" 85 | assert a.name == "speed" 86 | assert a.unit == "m⋅s^-1" 87 | assert str(a[4]) == "speed_4 = 5.0 +/- 0.5 [m⋅s^-1]" 88 | 89 | a = a.append(6) 90 | assert str(a[5]) == "speed_5 = 6 +/- 0 [m⋅s^-1]" 91 | 92 | a[3] = 10 93 | assert str(a[3]) == "speed_3 = 10.0 +/- 0.5 [m⋅s^-1]" 94 | a[4] = (10, 0.6) 95 | assert str(a[4]) == "speed_4 = 10.0 +/- 0.6 [m⋅s^-1]" 96 | 97 | with pytest.raises(TypeError): 98 | a[2] = 'a' 99 | 100 | b = q.MeasurementArray([5, 6, 7], 0.5) 101 | b[-1] = (8, 0.5) 102 | 103 | a = a.append(b) 104 | assert str(a[-1]) == "speed_8 = 8.0 +/- 0.5 [m⋅s^-1]" 105 | 106 | a = a.append([8, 9, 10]) 107 | assert str(a[-1]) == "speed_11 = 10 +/- 0 [m⋅s^-1]" 108 | 109 | def test_calculations_with_measurement_array(self): 110 | """tests for calculating properties of a measurement array""" 111 | 112 | a = q.MeasurementArray([1, 2, 3, 4, 5]) 113 | assert a.mean() == 3 114 | assert a.std() == pytest.approx(1.58113883008419) 115 | assert a.sum() == 15 116 | assert a.error_on_mean() == pytest.approx(0.707106781186548) 117 | 118 | with pytest.warns(UserWarning): 119 | assert np.isnan(a.error_weighted_mean()) 120 | with pytest.warns(UserWarning): 121 | assert np.isnan(a.propagated_error()) 122 | 123 | b = q.MeasurementArray([1, 2, 3, 4, 5], [0.1, 0.2, 0.3, 0.4, 0.5]) 124 | assert b.error_weighted_mean() == pytest.approx(1.5600683241601823) 125 | assert b.propagated_error() == pytest.approx(0.08265842980736918) 126 | 127 | 128 | class TestXYDataSet: 129 | """tests for the XYDataSet class""" 130 | 131 | def test_construct_data_set(self): 132 | """test for various ways to construct a data set""" 133 | 134 | with pytest.raises(ValueError): 135 | q.XYDataSet([0, 1, 2, 3, 4], [0, 1, 2, 3]) 136 | 137 | with pytest.raises(IllegalArgumentError): 138 | q.XYDataSet(0, 0) 139 | 140 | dataset = q.XYDataSet([0, 1, 2, 3, 4], [0, 0.2, 0.5, 0.8, 1.3], 141 | xerr=0.1, yerr=[0.1, 0.1, 0.1, 0.1, 0.5], name="test", 142 | xname="time", xunit="s", yname="distance", yunit="m") 143 | assert dataset.xname == "time" 144 | assert dataset.xunit == "s" 145 | assert dataset.yname == "distance" 146 | assert dataset.yunit == "m" 147 | assert dataset.name == "test" 148 | 149 | assert all(dataset.xvalues == [0, 1, 2, 3, 4]) 150 | assert all(dataset.xerr == [0.1, 0.1, 0.1, 0.1, 0.1]) 151 | assert all(dataset.yvalues == [0, 0.2, 0.5, 0.8, 1.3]) 152 | assert all(dataset.yerr == [0.1, 0.1, 0.1, 0.1, 0.5]) 153 | 154 | a = q.MeasurementArray([1, 2, 3, 4, 5]) 155 | b = q.MeasurementArray([10, 20, 30, 40, 50]) 156 | dataset = q.XYDataSet(a, b, xerr=0.5, yerr=0.5, name="test", 157 | xname="x", yname="y", xunit="m", yunit="s") 158 | assert dataset.name == "test" 159 | assert all(dataset.xerr == [0.5, 0.5, 0.5, 0.5, 0.5]) 160 | assert str(dataset.xdata[0]) == "x_0 = 1.0 +/- 0.5 [m]" 161 | 162 | c = q.MeasurementArray([1, 2, 3, 4, 5]) 163 | d = q.MeasurementArray([10, 20, 30, 40, 50]) 164 | dataset = q.XYDataSet(c, d) 165 | assert all(dataset.xerr == [0, 0, 0, 0, 0]) 166 | assert str(dataset.xdata[0]) == "1 +/- 0" 167 | 168 | def test_manipulate_data_set(self): 169 | """tests for changing values in a data set""" 170 | 171 | dataset = q.XYDataSet([0, 1, 2, 3, 4], [0, 0.2, 0.5, 0.8, 1.3]) 172 | dataset.name = "test" 173 | assert dataset.name == "test" 174 | dataset.xname = "x" 175 | assert dataset.xname == "x" 176 | dataset.xunit = "m" 177 | assert dataset.xunit == "m" 178 | assert str(dataset.xdata[0]) == "x_0 = 0 +/- 0 [m]" 179 | dataset.yname = "y" 180 | assert dataset.yname == "y" 181 | dataset.yunit = "s" 182 | assert dataset.yunit == "s" 183 | assert str(dataset.ydata[0]) == "y_0 = 0 +/- 0 [s]" 184 | 185 | with pytest.raises(TypeError): 186 | dataset.name = 1 187 | with pytest.raises(TypeError): 188 | dataset.xname = 1 189 | with pytest.raises(TypeError): 190 | dataset.xunit = 1 191 | with pytest.raises(TypeError): 192 | dataset.yname = 1 193 | with pytest.raises(TypeError): 194 | dataset.yunit = 1 195 | -------------------------------------------------------------------------------- /tests/test_error_propagation.py: -------------------------------------------------------------------------------- 1 | """Tests for different error propagation methods""" 2 | 3 | import pytest 4 | import qexpy as q 5 | 6 | from qexpy.data.data import ExperimentalValue, MeasuredValue 7 | from qexpy.utils.exceptions import IllegalArgumentError 8 | 9 | 10 | class TestDerivedValue: 11 | """tests for the derived value class""" 12 | 13 | @pytest.fixture(autouse=True) 14 | def reset_environment(self): 15 | """resets all default configurations""" 16 | q.get_settings().reset() 17 | q.reset_correlations() 18 | 19 | def test_derivative_method(self): 20 | """tests error propagation using the derivative method""" 21 | 22 | a = q.Measurement(5, 0.5) 23 | b = q.Measurement(2, 0.2) 24 | 25 | res = q.sqrt((a + b) / 2) 26 | assert res.error == pytest.approx(0.0719622917128924443443) 27 | assert str(res) == "1.87 +/- 0.07" 28 | 29 | def test_monte_carlo_method(self): 30 | """tests error propagation using the monte carlo method""" 31 | 32 | q.set_error_method(q.ErrorMethod.MONTE_CARLO) 33 | 34 | a = q.Measurement(5, 0.5) 35 | b = q.Measurement(2, 0.2) 36 | 37 | res = q.sqrt((a + b) / 2) 38 | assert res.error == pytest.approx(0.071962291712, abs=1e-2) 39 | assert str(res) == "1.87 +/- 0.07" 40 | 41 | res.mc.sample_size = 10000000 42 | assert res.mc.samples().size == 10000000 43 | assert res.error == pytest.approx(0.071962291712, abs=1e-3) 44 | 45 | res.mc.reset_sample_size() 46 | assert res.mc.sample_size == 10000 47 | 48 | with pytest.raises(ValueError): 49 | res.mc.sample_size = -1 50 | 51 | G = 6.67384e-11 # the gravitational constant 52 | m1 = q.Measurement(40e4, 2e4, name="m1", unit="kg") 53 | m2 = q.Measurement(30e4, 10e4, name="m2", unit="kg") 54 | r = q.Measurement(3.2, 0.5, name="distance", unit="m") 55 | 56 | f = G * m1 * m2 / (r ** 2) 57 | 58 | f.mc.confidence = 0.68 59 | assert f.mc.confidence == 0.68 60 | 61 | f.mc.use_mode_with_confidence() 62 | assert f.value == pytest.approx(0.68, abs=0.15) 63 | assert f.error == pytest.approx(0.36, abs=0.15) 64 | 65 | with pytest.raises(ValueError): 66 | f.mc.confidence = -1 67 | with pytest.raises(TypeError): 68 | f.mc.confidence = '1' 69 | 70 | f.mc.use_mode_with_confidence(0.3) 71 | assert f.error == pytest.approx(0.15, abs=0.15) 72 | 73 | f.mc.confidence = 0.3 74 | assert f.error == pytest.approx(0.15, abs=0.15) 75 | 76 | f.mc.use_mean_and_std() 77 | assert f.value == pytest.approx(0.848, abs=0.15) 78 | assert f.error == pytest.approx(0.435, abs=0.15) 79 | 80 | f.mc.sample_size = 10000000 81 | f.mc.set_xrange(-1, 4) 82 | 83 | assert f.value == pytest.approx(0.848, abs=0.05) 84 | assert f.error == pytest.approx(0.435, abs=0.05) 85 | 86 | with pytest.raises(TypeError): 87 | f.mc.set_xrange('1') 88 | with pytest.raises(ValueError): 89 | f.mc.set_xrange(4, 1) 90 | 91 | f.mc.set_xrange() 92 | assert f.mc.xrange == () 93 | 94 | f.mc.use_custom_value_and_error(0.8, 0.4) 95 | assert f.value == 0.8 96 | assert f.error == 0.4 97 | 98 | with pytest.raises(TypeError): 99 | f.mc.use_custom_value_and_error('a', 0.4) 100 | with pytest.raises(TypeError): 101 | f.mc.use_custom_value_and_error(0.8, 'a') 102 | with pytest.raises(ValueError): 103 | f.mc.use_custom_value_and_error(0.8, -0.5) 104 | 105 | f.recalculate() 106 | assert f.value == pytest.approx(0.848, abs=0.15) 107 | assert f.error == pytest.approx(0.435, abs=0.15) 108 | 109 | k = q.Measurement(0.01, 0.1) 110 | res = q.log(k) 111 | with pytest.warns(UserWarning): 112 | assert res.value != pytest.approx(-4.6) 113 | 114 | def test_correlated_measurements(self): 115 | """tests error propagation for correlated measurements""" 116 | 117 | a = q.Measurement(5, 0.5) 118 | b = q.Measurement(2, 0.2) 119 | 120 | q.set_covariance(a, b, 0.08) 121 | 122 | res = q.sqrt((a + b) / 2) 123 | assert res.error == pytest.approx(0.08964214570007952299766) 124 | 125 | res.error_method = q.ErrorMethod.MONTE_CARLO 126 | assert res.error == pytest.approx(0.0896421457001, abs=1e-2) 127 | 128 | def test_manipulate_derived_value(self): 129 | """unit tests for the derived value class""" 130 | 131 | a = q.Measurement(5, 0.5) 132 | b = q.Measurement(2, 0.5) 133 | 134 | res = a + b 135 | assert res.value == 7 136 | assert res.error == pytest.approx(0.7071067811865476) 137 | assert res.relative_error == pytest.approx(0.1010152544552210749) 138 | 139 | assert res.derivative(a) == 1 140 | 141 | with pytest.raises(IllegalArgumentError): 142 | res.derivative(1) 143 | 144 | res.error_method = q.ErrorMethod.MONTE_CARLO 145 | assert res.error_method == q.ErrorMethod.MONTE_CARLO 146 | 147 | res.error_method = "derivative" 148 | assert res.error_method == q.ErrorMethod.DERIVATIVE 149 | 150 | res.reset_error_method() 151 | assert res.error_method == q.ErrorMethod.DERIVATIVE 152 | 153 | with pytest.raises(ValueError): 154 | res.error_method = "hello" 155 | 156 | with pytest.raises(TypeError): 157 | res.value = 'a' 158 | with pytest.raises(TypeError): 159 | res.error = 'a' 160 | with pytest.raises(ValueError): 161 | res.error = -1 162 | with pytest.raises(TypeError): 163 | res.relative_error = 'a' 164 | with pytest.raises(ValueError): 165 | res.relative_error = -1 166 | 167 | with pytest.warns(UserWarning): 168 | res.value = 6 169 | assert res.value == 6 170 | assert isinstance(res, MeasuredValue) 171 | 172 | res = a + b 173 | with pytest.warns(UserWarning): 174 | res.error = 0.5 175 | assert res.error == 0.5 176 | assert isinstance(res, MeasuredValue) 177 | 178 | res = a + b 179 | with pytest.warns(UserWarning): 180 | res.relative_error = 0.5 181 | assert res.relative_error == 0.5 182 | assert isinstance(res, MeasuredValue) 183 | -------------------------------------------------------------------------------- /tests/test_fitting.py: -------------------------------------------------------------------------------- 1 | """Tests for the fitting sub-package""" 2 | 3 | import pytest 4 | import qexpy as q 5 | import numpy as np 6 | 7 | from qexpy.data.datasets import XYDataSet, ExperimentalValueArray 8 | from qexpy.utils.exceptions import IllegalArgumentError 9 | 10 | 11 | class TestFitting: 12 | """tests for fitting functions to datasets""" 13 | 14 | def test_fit_result(self): 15 | """tests for the fit result object""" 16 | 17 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 18 | b = [5.14440433, 7.14299315, 9.19825169, 11.04786137, 12.98168509, 19 | 15.33559568, 16.92760861, 18.80124373, 21.34893411, 23.16547138] 20 | 21 | result = q.fit(a, b, model=q.FitModel.LINEAR) 22 | 23 | slope, intercept = result.params[0], result.params[1] 24 | assert slope.value == pytest.approx(2, abs=slope.error) 25 | assert intercept.value == pytest.approx(3, abs=intercept.error) 26 | assert result[0].value == pytest.approx(2, abs=result[0].error) 27 | assert result[1].value == pytest.approx(3, abs=result[1].error) 28 | 29 | assert slope.name == "slope" 30 | assert intercept.name == "intercept" 31 | 32 | assert callable(result.fit_function) 33 | test = result.fit_function(3) 34 | assert test.value == pytest.approx(9, abs=0.2) 35 | 36 | residuals = result.residuals 37 | assert all(residuals < 0.3) 38 | 39 | assert result.ndof == 7 40 | assert result.chi_squared == pytest.approx(0) 41 | 42 | assert isinstance(result.dataset, XYDataSet) 43 | assert all(result.dataset.xdata == a) 44 | assert all(result.dataset.ydata == b) 45 | 46 | assert str(result) 47 | 48 | b[-1] = 50 49 | 50 | result = q.fit(a, b, model="linear", xrange=(0, 10)) 51 | assert result.xrange == (0, 10) 52 | assert result[0].value == pytest.approx(2, abs=0.15) 53 | assert result[1].value == pytest.approx(3, abs=0.15) 54 | 55 | def test_polynomial_fit(self): 56 | """tests for fitting to a polynomial""" 57 | 58 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 59 | b = [5.82616885, 10.73323542, 18.53401063, 27.16662982, 37.99711327, 60 | 51.41386193, 66.09297228, 83.46407479, 102.23573159, 122.8573845] 61 | 62 | result = q.fit(a, b, model=q.FitModel.QUADRATIC) 63 | 64 | assert len(result.params) == 3 65 | assert result[0].value == pytest.approx(1, rel=0.15) 66 | assert result[1].value == pytest.approx(2, rel=0.15) 67 | assert result[2].value == pytest.approx(3, rel=0.15) 68 | 69 | a = q.MeasurementArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 70 | b = q.MeasurementArray( 71 | [9.96073312, 31.18583676, 78.11727423, 161.58352404, 298.7038423, 494.25761959, 72 | 766.3146814, 1123.59437138, 1578.30697946, 2142.70591363]) 73 | 74 | result = q.fit(a, b, model=q.FitModel.POLYNOMIAL) 75 | 76 | assert len(result.params) == 4 77 | assert result[0].value == pytest.approx(2, rel=0.3) 78 | assert result[1].value == pytest.approx(1, rel=0.3) 79 | assert result[2].value == pytest.approx(4, rel=0.3) 80 | assert result[3].value == pytest.approx(3, rel=0.4) 81 | 82 | b = [20.32132071, 64.27190108, 189.14762997, 469.97259457, 999.96248493, 83 | 1899.41641639, 3312.43244643, 5411.38221041, 8379.45187783, 12439.47094005] 84 | 85 | dataset = q.XYDataSet(a, b, yerr=0.2) 86 | result = q.fit(dataset, model=q.FitModel.POLYNOMIAL, degrees=4) 87 | 88 | assert len(result.params) == 5 89 | assert result[0].value == pytest.approx(1, rel=0.3) 90 | assert result[1].value == pytest.approx(2, rel=0.3) 91 | assert result[2].value == pytest.approx(4, rel=0.3) 92 | assert result[3].value == pytest.approx(3, rel=0.4) 93 | assert result[4].value == pytest.approx(10, rel=0.4) 94 | 95 | with pytest.raises(IllegalArgumentError): 96 | q.fit(1, 2) 97 | 98 | def test_gaussian_fit(self): 99 | """tests for fitting to a gaussian distribution""" 100 | 101 | data = np.random.normal(20, 10, 10000) 102 | n, bins = np.histogram(data) 103 | centers = [(bins[i] + bins[i + 1]) / 2 for i in range(len(bins) - 1)] 104 | 105 | result = q.fit(centers, n, model="gaussian", parguess=[10000, 18, 9]) 106 | assert result[1].value == pytest.approx(20, rel=0.3) 107 | assert result[2].value == pytest.approx(10, rel=0.3) 108 | 109 | def test_exponential_fit(self): 110 | """tests for fitting to an exponential function""" 111 | 112 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] 113 | b = [3.06297029e+00, 1.84077449e+00, 1.12743994e+00, 6.70756404e-01, 4.13320658e-01, 114 | 2.46274429e-01, 1.48775210e-01, 9.22527208e-02, 5.51925037e-02, 3.39932113e-02, 115 | 2.01913759e-02, 1.24795552e-02, 7.67283714e-03, 4.55000537e-03, 2.75573044e-03, 116 | 1.72345608e-03, 1.00990816e-03, 6.21266331e-04, 3.75164648e-04, 2.26534182e-04] 117 | 118 | result = q.fit(a, b, model=q.FitModel.EXPONENTIAL, parguess=[4.5, 0.45]) 119 | assert result[0].value == pytest.approx(5, rel=0.1) 120 | assert result[1].value == pytest.approx(0.5, rel=0.1) 121 | 122 | assert result[0].name == "amplitude" 123 | assert result[1].name == "decay constant" 124 | 125 | def test_custom_fit(self): 126 | """tests for fitting to a custom function""" 127 | 128 | arr1 = [0.5, 1., 1.5, 2., 2.5, 3., 3.5, 4., 4.5, 5., 129 | 5.5, 6., 6.5, 7., 7.5, 8., 8.5, 9., 9.5, 10.] 130 | arr2 = [0.84323953, 1.62678942, 2.46301834, 2.96315268, 3.64614702, 131 | 4.11468579, 4.61486981, 4.76099487, 5.04725257, 4.9774383, 132 | 4.85234697, 4.55749775, 4.13774772, 3.64002414, 3.01167174, 133 | 2.26087356, 1.48618986, 0.71204259, -0.0831691, -0.9100453] 134 | 135 | def model(x, a, b): 136 | return a * q.sin(b * x) 137 | 138 | result = q.fit(arr1, arr2, model=model, parguess=[4, 0.5], parunits=["m", "kg"]) 139 | assert result[0].name == "a" 140 | assert result[1].name == "b" 141 | assert result[0].unit == "m" 142 | assert result[1].unit == "kg" 143 | assert result[0].value == pytest.approx(5, rel=0.5) 144 | assert result[1].value == pytest.approx(0.5, rel=0.5) 145 | 146 | with pytest.raises(ValueError): 147 | q.fit(arr1, arr2, model="model", parguess=[4, 0.5]) 148 | 149 | with pytest.warns(UserWarning): 150 | q.fit(arr1, arr2, model=model) 151 | 152 | with pytest.raises(TypeError): 153 | q.fit(arr1, arr2, model=model, parguess=['a', 0.5]) 154 | 155 | with pytest.raises(TypeError): 156 | q.fit(arr1, arr2, model=model, parguess=[4, 0.5], parnames=[1, 2]) 157 | 158 | with pytest.raises(TypeError): 159 | q.fit(arr1, arr2, model=model, parguess=[4, 0.5], parunits=[1, 2]) 160 | 161 | with pytest.raises(IllegalArgumentError): 162 | q.fit(arr1, arr2, model=model, parguess=4) 163 | 164 | with pytest.raises(ValueError): 165 | q.fit(arr1, arr2, model=model, parguess=[4, 0.5], parunits=["m"]) 166 | 167 | def func(x, **kwargs): 168 | return kwargs.get("a") * q.sin(kwargs.get("b") * x) # pragma: no cover 169 | 170 | with pytest.raises(ValueError): 171 | q.fit(arr1, arr2, model=func, parguess=[4, 0.5]) 172 | 173 | def func2(x): 174 | return x # pragma: no cover 175 | 176 | with pytest.raises(ValueError): 177 | q.fit(arr1, arr2, model=func2, parguess=[4, 0.5]) 178 | 179 | def func3(x, *args): 180 | return args[0] * q.sin(args[1] * x) 181 | 182 | dataset = q.XYDataSet(arr1, arr2, xerr=0.05, yerr=0.05) 183 | result = dataset.fit(model=func3, parguess=[4, 0.5], parnames=["arr1", "arr2"]) 184 | assert result[0].name == "arr1" 185 | assert result[1].name == "arr2" 186 | 187 | with pytest.raises(ValueError): 188 | dataset.fit(model=func3, parguess=[4, 0.5], parnames=["arr1"]) 189 | 190 | def func4(x, a, *args): 191 | return a + args[0] * q.sin(args[1] * x) # pragma: no cover 192 | 193 | with pytest.raises(ValueError): 194 | with pytest.warns(UserWarning): 195 | q.fit(arr1, arr2, model=func4, parnames=["arr1"]) 196 | -------------------------------------------------------------------------------- /tests/test_measurements.py: -------------------------------------------------------------------------------- 1 | """Unit tests for recording individual and arrays of measurements""" 2 | 3 | import pytest 4 | 5 | import numpy as np 6 | import qexpy as q 7 | 8 | from qexpy.data.data import RepeatedlyMeasuredValue, MeasuredValue, UndefinedActionError 9 | from qexpy.data.datasets import ExperimentalValueArray 10 | from qexpy.utils.exceptions import IllegalArgumentError 11 | 12 | 13 | class TestMeasuredValue: 14 | """Tests for a single measurement""" 15 | 16 | @pytest.fixture(autouse=True) 17 | def reset_environment(self): 18 | """restores all default configurations before each testcase""" 19 | q.get_settings().reset() 20 | 21 | def test_measurement(self): 22 | """tests for single measurements""" 23 | 24 | a = q.Measurement(5) 25 | assert a.value == 5 26 | assert a.error == 0 27 | assert str(a) == "5 +/- 0" 28 | assert repr(a) == "MeasuredValue(5 +/- 0)" 29 | 30 | b = q.Measurement(5, 0.5) 31 | assert b.value == 5 32 | assert b.error == 0.5 33 | assert b.relative_error == 0.1 34 | assert b.std == 0.5 35 | assert str(b) == "5.0 +/- 0.5" 36 | assert repr(b) == "MeasuredValue(5.0 +/- 0.5)" 37 | 38 | c = q.Measurement(12.34, 0.05, name="energy", unit="kg*m^2*s^-2") 39 | assert str(c) == "energy = 12.34 +/- 0.05 [kg⋅m^2⋅s^-2]" 40 | 41 | q.set_sig_figs_for_error(2) 42 | assert str(c) == "energy = 12.340 +/- 0.050 [kg⋅m^2⋅s^-2]" 43 | q.set_sig_figs_for_value(4) 44 | assert str(c) == "energy = 12.34 +/- 0.05 [kg⋅m^2⋅s^-2]" 45 | q.set_unit_style(q.UnitStyle.FRACTION) 46 | assert str(c) == "energy = 12.34 +/- 0.05 [kg⋅m^2/s^2]" 47 | 48 | assert c.derivative(c) == 1 49 | assert c.derivative(b) == 0 50 | 51 | with pytest.raises(IllegalArgumentError): 52 | c.derivative(1) 53 | 54 | with pytest.raises(IllegalArgumentError): 55 | q.Measurement('12.34') 56 | 57 | with pytest.raises(IllegalArgumentError): 58 | q.Measurement(12.34, '0.05') 59 | 60 | with pytest.raises(TypeError): 61 | q.Measurement(5, unit=1) 62 | 63 | with pytest.raises(TypeError): 64 | q.Measurement(5, name=1) 65 | 66 | def test_measurement_setters(self): 67 | """tests for changing values in a measured value""" 68 | 69 | a = q.Measurement(12.34, 0.05) 70 | a.value = 50 71 | assert a.value == 50 72 | a.error = 0.02 73 | assert a.error == 0.02 74 | a.relative_error = 0.05 75 | assert a.relative_error == 0.05 76 | assert a.error == 2.5 77 | a.name = "energy" 78 | assert a.name == "energy" 79 | a.unit = "kg*m^2/s^2" 80 | assert a.unit == "kg⋅m^2⋅s^-2" 81 | 82 | with pytest.raises(TypeError): 83 | a.value = '1' 84 | 85 | with pytest.raises(TypeError): 86 | a.error = '1' 87 | 88 | with pytest.raises(ValueError): 89 | a.error = -1 90 | 91 | with pytest.raises(TypeError): 92 | a.relative_error = '1' 93 | 94 | with pytest.raises(ValueError): 95 | a.relative_error = -1 96 | 97 | with pytest.raises(TypeError): 98 | a.name = 1 99 | 100 | with pytest.raises(TypeError): 101 | a.unit = 1 102 | 103 | def test_repeated_measurement(self): 104 | """test recording repeatedly measured values""" 105 | 106 | a = q.Measurement([10, 9.8, 9.9, 10.1, 10.2]) 107 | assert isinstance(a, RepeatedlyMeasuredValue) 108 | assert a.value == 10 109 | assert a.error == pytest.approx(0.070710730438880) 110 | assert a.std == pytest.approx(0.158114) 111 | a.use_std_for_uncertainty() 112 | assert a.error == pytest.approx(0.158114) 113 | 114 | assert isinstance(a.raw_data, np.ndarray) 115 | assert not isinstance(a.raw_data, ExperimentalValueArray) 116 | 117 | b = q.Measurement([10, 9.8, 9.9, 10.1, 10.2], [0.5, 0.3, 0.1, 0.2, 0.2]) 118 | assert isinstance(b, RepeatedlyMeasuredValue) 119 | assert b.mean == 10 120 | assert b.value == 10 121 | assert b.error == pytest.approx(0.070710730438880) 122 | 123 | assert isinstance(b.raw_data, ExperimentalValueArray) 124 | assert all(b.raw_data == [10, 9.8, 9.9, 10.1, 10.2]) 125 | 126 | with pytest.raises(ValueError): 127 | q.Measurement([10, 9.8, 9.9, 10.1, 10.2], [0.5, 0.3, 0.1, 0.2]) 128 | 129 | def test_repeated_measurement_setters(self): 130 | """test that the setters for repeated measurements behave correctly""" 131 | 132 | a = q.Measurement([10, 9.8, 9.9, 10.1, 10.2], [0.5, 0.3, 0.1, 0.2, 0.2]) 133 | assert isinstance(a, RepeatedlyMeasuredValue) 134 | a.use_error_weighted_mean_as_value() 135 | assert a.error_weighted_mean == 9.971399730820997 136 | assert a.value == 9.971399730820997 137 | a.use_error_on_mean_for_uncertainty() 138 | assert a.error_on_mean == pytest.approx(0.070710678118654) 139 | assert a.error == pytest.approx(0.070710678118654) 140 | a.use_propagated_error_for_uncertainty() 141 | assert a.propagated_error == 0.0778236955614928 142 | assert a.error == 0.0778236955614928 143 | 144 | with pytest.raises(TypeError): 145 | a.value = '15' 146 | 147 | with pytest.warns(UserWarning): 148 | a.value = 15 149 | 150 | assert not isinstance(a, RepeatedlyMeasuredValue) 151 | assert isinstance(a, MeasuredValue) 152 | assert a.value == 15 153 | 154 | def test_correlation_for_repeated_measurements(self): 155 | """test covariance and correlation settings between repeated measurements""" 156 | 157 | a = q.Measurement([0.8, 0.9, 1, 1.1]) 158 | b = q.Measurement([2, 2.2, 2.1, 2.3]) 159 | 160 | assert q.get_correlation(a, b) == 0 161 | assert q.get_covariance(a, b) == 0 162 | 163 | q.set_correlation(a, b) 164 | assert q.get_correlation(a, b) == pytest.approx(0.8) 165 | assert q.get_covariance(a, b) == pytest.approx(0.01333333333) 166 | 167 | q.set_correlation(a, b, 0) 168 | assert q.get_correlation(a, b) == 0 169 | assert q.get_covariance(a, b) == 0 170 | 171 | a.set_covariance(b) 172 | assert q.get_correlation(a, b) == pytest.approx(0.8) 173 | assert q.get_covariance(a, b) == pytest.approx(0.01333333333) 174 | 175 | q.set_covariance(a, b, 0) 176 | assert q.get_correlation(a, b) == 0 177 | assert q.get_covariance(a, b) == 0 178 | 179 | d = a + b 180 | with pytest.raises(IllegalArgumentError): 181 | a.get_covariance(0) 182 | with pytest.raises(IllegalArgumentError): 183 | a.get_correlation(0) 184 | with pytest.raises(IllegalArgumentError): 185 | a.set_covariance(0, 0) 186 | with pytest.raises(IllegalArgumentError): 187 | a.set_correlation(0, 0) 188 | with pytest.raises(IllegalArgumentError): 189 | a.set_covariance(d, 0) 190 | with pytest.raises(IllegalArgumentError): 191 | a.set_correlation(d, 0) 192 | 193 | c = q.Measurement([0, 1, 2]) 194 | with pytest.raises(IllegalArgumentError): 195 | q.set_covariance(a, c) 196 | with pytest.raises(IllegalArgumentError): 197 | q.set_correlation(a, c) 198 | 199 | def test_correlation_for_single_measurements(self): 200 | """test covariance and correlation between single measurements""" 201 | 202 | a = q.Measurement(5, 0.5) 203 | b = q.Measurement(6, 0.2) 204 | c = q.Measurement(5) 205 | d = a + b 206 | 207 | assert a.get_covariance(a) == 0.25 208 | assert a.get_correlation(a) == 1 209 | assert a.get_covariance(c) == 0 210 | assert a.get_correlation(c) == 0 211 | 212 | assert d.get_covariance(a) == 0 213 | assert d.get_correlation(a) == 0 214 | assert a.get_covariance(d) == 0 215 | assert a.get_correlation(d) == 0 216 | assert q.get_covariance(a, d) == 0 217 | assert q.get_correlation(a, d) == 0 218 | 219 | def test_illegal_correlation_settings(self): 220 | """test illegal correlation and covariance settings""" 221 | 222 | a = q.Measurement(5, 0.5) 223 | b = q.Measurement(6, 0.2) 224 | c = q.Measurement(5) 225 | d = a + b 226 | 227 | with pytest.raises(IllegalArgumentError): 228 | q.set_correlation(a, 0, 0) 229 | with pytest.raises(IllegalArgumentError): 230 | q.set_covariance(a, 0, 0) 231 | with pytest.raises(IllegalArgumentError): 232 | a.set_correlation(0, 0) 233 | with pytest.raises(IllegalArgumentError): 234 | a.set_covariance(0, 0) 235 | with pytest.raises(UndefinedActionError): 236 | d.set_correlation(a, 0) 237 | with pytest.raises(UndefinedActionError): 238 | d.set_covariance(a, 0) 239 | with pytest.raises(IllegalArgumentError): 240 | a.set_covariance(d, 0) 241 | with pytest.raises(IllegalArgumentError): 242 | a.set_correlation(d, 0) 243 | with pytest.raises(ArithmeticError): 244 | a.set_covariance(c, 1) 245 | with pytest.raises(ArithmeticError): 246 | a.set_correlation(c, 1) 247 | 248 | with pytest.raises(IllegalArgumentError): 249 | a.get_correlation(0) 250 | with pytest.raises(IllegalArgumentError): 251 | a.get_covariance(0) 252 | with pytest.raises(IllegalArgumentError): 253 | q.get_correlation(a, 0) 254 | with pytest.raises(IllegalArgumentError): 255 | q.get_covariance(a, 0) 256 | 257 | with pytest.raises(ValueError): 258 | q.set_covariance(a, b, 100) 259 | with pytest.raises(ValueError): 260 | q.set_correlation(a, b, 2) 261 | -------------------------------------------------------------------------------- /tests/test_operations.py: -------------------------------------------------------------------------------- 1 | """Tests for operations between experimental values""" 2 | 3 | import pytest 4 | import qexpy as q 5 | 6 | from qexpy.data.data import ExperimentalValue 7 | from qexpy.utils.exceptions import UndefinedOperationError 8 | 9 | 10 | class TestArithmetic: 11 | """tests for basic arithmetic operations""" 12 | 13 | def test_value_comparison(self): 14 | """tests for comparing values""" 15 | 16 | a = q.Measurement(4, 0.5, unit="m") 17 | b = q.Measurement(10, 2, unit="m") 18 | c = q.Measurement(10, 1) 19 | 20 | assert a < b 21 | assert a <= b 22 | assert b >= a 23 | assert a > 2 24 | assert 3 < b 25 | assert a == 4 26 | assert 10 == b 27 | assert b == c 28 | 29 | def test_elementary_operations(self): 30 | """tests for elementary arithmetic operations""" 31 | 32 | a = q.Measurement(4, 0.5, unit="m") 33 | b = q.Measurement(10, 2, unit="m") 34 | 35 | c = a + b 36 | assert c.value == 14 37 | assert c.error == pytest.approx(2.0615528128088303) 38 | assert str(c) == "14 +/- 2 [m]" 39 | 40 | c2 = a + 2 41 | assert c2.value == 6 42 | assert c2.error == 0.5 43 | assert str(c2) == "6.0 +/- 0.5 [m]" 44 | 45 | c3 = 5 + a 46 | assert c3.value == 9 47 | assert c3.error == 0.5 48 | 49 | c4 = a + (10, 2) 50 | assert c4.value == 14 51 | assert c4.error == pytest.approx(2.0615528128088303) 52 | assert str(c4) == "14 +/- 2" 53 | 54 | c5 = -a 55 | assert str(c5) == "-4.0 +/- 0.5 [m]" 56 | 57 | h = b - a 58 | assert h.value == 6 59 | assert h.error == pytest.approx(2.0615528128088303) 60 | assert str(h) == "6 +/- 2 [m]" 61 | 62 | h1 = a - 2 63 | assert h1.value == 2 64 | assert h1.error == 0.5 65 | assert str(h1) == "2.0 +/- 0.5 [m]" 66 | 67 | h2 = 5 - a 68 | assert h2.value == 1 69 | assert h2.error == 0.5 70 | 71 | f = q.Measurement(4, 0.5, unit="kg*m/s^2") 72 | d = q.Measurement(10, 2, unit="m") 73 | 74 | e = f * d 75 | assert e.value == 40 76 | assert e.error == pytest.approx(9.433981132056603) 77 | assert str(e) == "40 +/- 9 [kg⋅m^2⋅s^-2]" 78 | 79 | e1 = f * 2 80 | assert e1.value == 8 81 | assert str(e1) == "8 +/- 1 [kg⋅m⋅s^-2]" 82 | 83 | e2 = 2 * f 84 | assert e2.value == 8 85 | 86 | s = q.Measurement(10, 2, unit="m") 87 | t = q.Measurement(4, 0.5, unit="s") 88 | 89 | v = s / t 90 | assert v.value == 2.5 91 | assert v.error == pytest.approx(0.5896238207535377) 92 | assert str(v) == "2.5 +/- 0.6 [m⋅s^-1]" 93 | 94 | v1 = 20 / s 95 | assert v1.value == 2 96 | assert str(v1) == "2.0 +/- 0.4 [m^-1]" 97 | 98 | v2 = s / 2 99 | assert v2.value == 5 100 | 101 | with pytest.raises(UndefinedOperationError): 102 | s + 'a' 103 | 104 | k = q.Measurement(5, 0.5, unit="m") 105 | 106 | m = k ** 2 107 | assert str(m) == "25 +/- 5 [m^2]" 108 | 109 | n = 2 ** k 110 | assert n.value == 32 111 | assert n.error == pytest.approx(11.09035488895912495) 112 | assert str(n) == "30 +/- 10" 113 | 114 | def test_vectorized_arithmetic(self): 115 | """tests for arithmetic with experimental value arrays""" 116 | 117 | a = q.MeasurementArray([1, 2, 3, 4, 5], 0.5, unit="s") 118 | 119 | res = a + 2 120 | assert all(res.values == [3, 4, 5, 6, 7]) 121 | assert all(res.errors == [0.5, 0.5, 0.5, 0.5, 0.5]) 122 | assert res.unit == "s" 123 | 124 | res = 2 + a 125 | assert all(res.values == [3, 4, 5, 6, 7]) 126 | assert all(res.errors == [0.5, 0.5, 0.5, 0.5, 0.5]) 127 | assert res.unit == "s" 128 | 129 | res = a + (2, 0.5) 130 | assert all(res.values == [3, 4, 5, 6, 7]) 131 | 132 | res = (2, 0.5) + a 133 | assert all(res.values == [3, 4, 5, 6, 7]) 134 | 135 | res = q.Measurement(2, 0.5) + a 136 | assert all(res.values == [3, 4, 5, 6, 7]) 137 | 138 | res = a + [1, 2, 3, 4, 5] 139 | assert all(res.values == [2, 4, 6, 8, 10]) 140 | 141 | res = [1, 2, 3, 4, 5] + a 142 | assert all(res.values == [2, 4, 6, 8, 10]) 143 | 144 | res = a - 1 145 | assert all(res.values == [0, 1, 2, 3, 4]) 146 | 147 | res = 10 - a 148 | assert all(res.values == [9, 8, 7, 6, 5]) 149 | 150 | res = q.Measurement(10, 0.5) - a 151 | assert all(res.values == [9, 8, 7, 6, 5]) 152 | 153 | res = a - [1, 2, 3, 4, 5] 154 | assert all(res.values == [0, 0, 0, 0, 0]) 155 | 156 | res = [1, 2, 3, 4, 5] - a 157 | assert all(res.values == [0, 0, 0, 0, 0]) 158 | 159 | res = a * 2 160 | assert all(res.values == [2, 4, 6, 8, 10]) 161 | 162 | res = 2 * a 163 | assert all(res.values == [2, 4, 6, 8, 10]) 164 | 165 | res = q.Measurement(2, 0.5) * a 166 | assert all(res.values == [2, 4, 6, 8, 10]) 167 | 168 | b = q.MeasurementArray([10, 20, 30, 40, 50], 0.5, unit="m") 169 | 170 | res = b * a 171 | assert all(res.values == [10, 40, 90, 160, 250]) 172 | assert res.unit == "m⋅s" 173 | 174 | res = [1, 2, 3, 4, 5] * a 175 | assert all(res.values == [1, 4, 9, 16, 25]) 176 | 177 | res = a / 2 178 | assert all(res.values == [0.5, 1, 1.5, 2, 2.5]) 179 | 180 | res = 2 / a 181 | assert all(res.values == [2, 1, 2 / 3, 2 / 4, 2 / 5]) 182 | 183 | res = q.Measurement(2, 0.5) / a 184 | assert all(res.values == [2, 1, 2 / 3, 2 / 4, 2 / 5]) 185 | 186 | res = b / a 187 | assert all(res.values == [10, 10, 10, 10, 10]) 188 | assert res.unit == "m⋅s^-1" 189 | 190 | res = [1, 2, 3, 4, 5] / a 191 | assert all(res.values == [1, 1, 1, 1, 1]) 192 | 193 | res = a ** 2 194 | assert all(res.values == [1, 4, 9, 16, 25]) 195 | 196 | res = 2 ** a 197 | assert all(res.values == [2, 4, 8, 16, 32]) 198 | 199 | res = q.Measurement(2, 0.5) ** a 200 | assert all(res.values == [2, 4, 8, 16, 32]) 201 | 202 | res = a ** [2, 2, 2, 2, 2] 203 | assert all(res.values == [1, 4, 9, 16, 25]) 204 | assert res.unit == "s^2" 205 | 206 | res = [2, 2, 2, 2, 2] ** a 207 | assert all(res.values == [2, 4, 8, 16, 32]) 208 | 209 | def test_composite_operations(self): 210 | """tests combining several operations""" 211 | 212 | d = q.Measurement(5, 0.1, unit="m") 213 | m = q.Measurement(10, 0.5, unit="kg") 214 | t = q.Measurement(2, 0.1, unit="s") 215 | 216 | v = d / t 217 | e = 1 / 2 * m * (v ** 2) 218 | 219 | assert e.value == 31.25 220 | assert e.error == pytest.approx(3.7107319021993495716) 221 | assert e.unit == "kg⋅m^2⋅s^-2" 222 | 223 | res = (d ** 2) ** (1 / 3) 224 | assert res.unit == "m^(2/3)" 225 | 226 | 227 | class TestMathFunctions: 228 | """tests for math function wrappers""" 229 | 230 | def test_math_functions(self): 231 | """tests for math functions on single values""" 232 | 233 | a = q.Measurement(4, 0.5, unit="m") 234 | 235 | res = q.sqrt(a) 236 | assert res.value == 2 237 | assert res.error == 0.125 238 | assert res.unit == "m^(1/2)" 239 | assert q.sqrt(4) == 2 240 | 241 | with pytest.raises(UndefinedOperationError): 242 | q.sqrt("a") 243 | 244 | res = q.exp(a) 245 | assert res.value == pytest.approx(54.598150033144239) 246 | assert res.error == pytest.approx(27.299075016572120) 247 | 248 | res = q.log(a) 249 | assert res.value == pytest.approx(1.3862943611198906) 250 | assert res.error == 0.125 251 | 252 | res = q.log(2, a) 253 | assert res.value == 2 254 | assert res.error == pytest.approx(0.1803368801111204) 255 | 256 | res = q.log10(a) 257 | assert res.value == pytest.approx(0.6020599913279624) 258 | assert res.error == pytest.approx(0.0542868102379065) 259 | 260 | with pytest.raises(TypeError): 261 | q.log(2, a, 2) 262 | 263 | def test_trig_functions(self): 264 | """tests for trigonometric functions""" 265 | 266 | a = q.Measurement(0.7853981633974483) 267 | b = q.Measurement(45) 268 | c = q.Measurement(0.5) 269 | 270 | res = q.sin(a) 271 | assert res.value == pytest.approx(0.7071067811865475244) 272 | 273 | res = q.sind(b) 274 | assert res.value == pytest.approx(0.7071067811865475244) 275 | 276 | res = q.cos(a) 277 | assert res.value == pytest.approx(0.7071067811865475244) 278 | 279 | res = q.cosd(b) 280 | assert res.value == pytest.approx(0.7071067811865475244) 281 | 282 | res = q.tan(a) 283 | assert res.value == pytest.approx(1) 284 | 285 | res = q.tand(b) 286 | assert res.value == pytest.approx(1) 287 | 288 | res = q.sec(a) 289 | assert res.value == pytest.approx(1.4142135623730950488) 290 | 291 | res = q.secd(b) 292 | assert res.value == pytest.approx(1.4142135623730950488) 293 | 294 | res = q.csc(a) 295 | assert res.value == pytest.approx(1.4142135623730950488) 296 | 297 | res = q.cscd(b) 298 | assert res.value == pytest.approx(1.4142135623730950488) 299 | 300 | res = q.cot(a) 301 | assert res.value == pytest.approx(1) 302 | 303 | res = q.cotd(b) 304 | assert res.value == pytest.approx(1) 305 | 306 | res = q.asin(c) 307 | assert res.value == pytest.approx(0.523598775598298873077) 308 | 309 | res = q.acos(c) 310 | assert res.value == pytest.approx(1.047197551196597746154) 311 | 312 | res = q.atan(c) 313 | assert res.value == pytest.approx(0.463647609000806116214) 314 | 315 | def test_vectorized_functions(self): 316 | """tests for functions on experimental value arrays""" 317 | 318 | a = q.MeasurementArray([1, 2, 3, 4, 5]) 319 | assert q.mean(a) == 3 320 | assert q.std(a) == pytest.approx(1.58113883008419) 321 | assert q.sum(a) == 15 322 | 323 | b = [1, 2, 3, 4, 5] 324 | 325 | res = q.mean(b) 326 | 327 | assert res == 3 328 | assert not isinstance(res, ExperimentalValue) 329 | 330 | assert q.std(b) == pytest.approx(1.58113883008419) 331 | assert q.sum(b) == 15 332 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the settings sub-package""" 2 | 3 | import pytest 4 | 5 | from qexpy.settings.settings import ErrorMethod, PrintStyle, UnitStyle, SigFigMode 6 | 7 | import qexpy.settings.literals as lit 8 | import qexpy.settings.settings as sts 9 | 10 | 11 | class TestSettings: 12 | """Unit tests for global settings""" 13 | 14 | def test_settings(self): 15 | """test change and get settings""" 16 | 17 | sts.set_unit_style(lit.FRACTION) 18 | assert sts.get_settings().unit_style == UnitStyle.FRACTION 19 | sts.set_unit_style(UnitStyle.EXPONENTS) 20 | assert sts.get_settings().unit_style == UnitStyle.EXPONENTS 21 | 22 | sts.set_print_style(PrintStyle.SCIENTIFIC) 23 | assert sts.get_settings().print_style == PrintStyle.SCIENTIFIC 24 | sts.set_print_style(lit.DEFAULT) 25 | assert sts.get_settings().print_style == PrintStyle.DEFAULT 26 | 27 | sts.set_error_method(ErrorMethod.MONTE_CARLO) 28 | assert sts.get_settings().error_method == ErrorMethod.MONTE_CARLO 29 | sts.set_error_method(lit.DERIVATIVE) 30 | assert sts.get_settings().error_method == ErrorMethod.DERIVATIVE 31 | 32 | sts.set_plot_dimensions((8, 4)) 33 | assert sts.get_settings().plot_dimensions == (8, 4) 34 | 35 | sts.set_monte_carlo_sample_size(10000) 36 | assert sts.get_settings().monte_carlo_sample_size == 10000 37 | 38 | sts.set_sig_figs_for_error(4) 39 | assert sts.get_settings().sig_fig_value == 4 40 | assert sts.get_settings().sig_fig_mode == SigFigMode.ERROR 41 | sts.set_sig_figs_for_value(3) 42 | assert sts.get_settings().sig_fig_value == 3 43 | assert sts.get_settings().sig_fig_mode == SigFigMode.VALUE 44 | 45 | def test_invalid_settings(self): 46 | """tests for rejecting invalid settings""" 47 | 48 | with pytest.raises(ValueError): 49 | sts.set_sig_figs_for_value(-1) 50 | 51 | with pytest.raises(ValueError): 52 | # noinspection PyTypeChecker 53 | sts.set_sig_figs_for_error(0.5) 54 | 55 | with pytest.raises(ValueError): 56 | sts.set_monte_carlo_sample_size(-1) 57 | 58 | with pytest.raises(ValueError): 59 | sts.set_plot_dimensions((0, 0)) 60 | 61 | with pytest.raises(ValueError): 62 | sts.set_error_method(lit.DEFAULT) 63 | 64 | with pytest.raises(ValueError): 65 | sts.set_print_style(lit.DERIVATIVE) 66 | 67 | with pytest.raises(ValueError): 68 | sts.set_unit_style(lit.DERIVATIVE) 69 | 70 | with pytest.raises(ValueError): 71 | sts.set_plot_dimensions(10) 72 | 73 | def test_reset_error_configurations(self): 74 | """test for reset all configurations to default""" 75 | 76 | sts.reset_default_configuration() 77 | assert sts.get_settings().error_method == ErrorMethod.DERIVATIVE 78 | assert sts.get_settings().sig_fig_mode == SigFigMode.AUTOMATIC 79 | assert sts.get_settings().sig_fig_value == 1 80 | assert sts.get_settings().monte_carlo_sample_size == 10000 81 | assert sts.get_settings().unit_style == UnitStyle.EXPONENTS 82 | assert sts.get_settings().plot_dimensions == (6.4, 4.8) 83 | 84 | def test_use_mc_sample_size(self): 85 | """test for temporarily setting monte-carlo sample size""" 86 | 87 | @sts.use_mc_sample_size(100) 88 | def test_func(): 89 | assert sts.get_settings().monte_carlo_sample_size == 100 90 | 91 | sts.set_monte_carlo_sample_size(10000) 92 | test_func() 93 | assert sts.get_settings().monte_carlo_sample_size == 10000 94 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the utility sub-package""" 2 | 3 | import os 4 | import pytest 5 | import numpy as np 6 | import qexpy as q 7 | 8 | from collections import OrderedDict 9 | 10 | import qexpy.settings.settings as sts 11 | import qexpy.settings.literals as lit 12 | import qexpy.utils.utils as utils 13 | import qexpy.utils.printing as printing 14 | import qexpy.utils.units as units 15 | 16 | from qexpy.utils.exceptions import UndefinedOperationError 17 | 18 | 19 | class TestDecorators: 20 | """Unit tests for various decorators in utils""" 21 | 22 | def test_check_operand_type(self): 23 | """test the operand type checker""" 24 | 25 | @utils.check_operand_type("test") 26 | def test_func(_): 27 | raise TypeError("test error") 28 | 29 | @utils.check_operand_type("+") 30 | def test_func2(_, __): 31 | raise TypeError("test error") 32 | 33 | with pytest.raises(UndefinedOperationError) as e: 34 | test_func('a') 35 | 36 | exp = "\"test\" is undefined with operands of type(s) 'str'. Expected: real numbers" 37 | assert str(e.value) == exp 38 | 39 | with pytest.raises(UndefinedOperationError) as e: 40 | test_func2('a', 1) 41 | exp = "\"+\" is undefined with operands of type(s) 'str' and 'int'. " \ 42 | "Expected: real numbers" 43 | assert str(e.value) == exp 44 | 45 | def test_vectorize(self): 46 | """test the vectorize decorator""" 47 | 48 | @utils.vectorize 49 | def test_func(a): 50 | return a + 2 51 | 52 | assert test_func([1, 2, 3]) == [3, 4, 5] 53 | assert test_func(1) == 3 54 | assert all(test_func(np.array([1, 2, 3])) == [3, 4, 5]) 55 | 56 | 57 | class TestUtils: 58 | """Unit tests for the utils sub-module""" 59 | 60 | def test_validate_xrange(self): 61 | """tests the range validator""" 62 | 63 | with pytest.raises(TypeError): 64 | utils.validate_xrange(0) 65 | with pytest.raises(TypeError): 66 | utils.validate_xrange((0,)) 67 | with pytest.raises(TypeError): 68 | utils.validate_xrange((0, '1')) 69 | with pytest.raises(ValueError): 70 | utils.validate_xrange((1, 0)) 71 | assert utils.validate_xrange((10.5, 20.5)) 72 | 73 | def test_numerical_derivative(self): 74 | """test the numerical derivative""" 75 | 76 | assert utils.numerical_derivative( 77 | lambda x: x ** 2 * np.sin(x), 2) == pytest.approx(1.9726023611141572335938) 78 | 79 | def test_calculate_covariance(self): 80 | """test the covariance calculator""" 81 | 82 | with pytest.raises(ValueError): 83 | utils.calculate_covariance([1, 2, 3], [1, 2, 3, 4]) 84 | 85 | assert utils.calculate_covariance([1, 2, 3, 4], [4, 3, 2, 1]) == pytest.approx(- 5 / 3) 86 | assert utils.calculate_covariance( 87 | np.array([1, 2, 3, 4]), np.array([4, 3, 2, 1])) == pytest.approx(- 5 / 3) 88 | 89 | def test_cov2corr(self): 90 | """test converting covariance matrix to correlation matrix""" 91 | 92 | m = np.array([[1, 2, 3, 4], [4, 3, 2, 1], [2, 3, 2, 3]]) 93 | assert utils.cov2corr(np.cov(m)) == pytest.approx(np.corrcoef(m)) 94 | 95 | def test_load_data_from_file(self): 96 | """test loading an array from a data file""" 97 | 98 | curr_path = os.path.abspath(os.path.dirname(__file__)) 99 | filename = os.path.join(curr_path, "./resources/data_for_test_load_data.csv") 100 | data = utils.load_data_from_file(filename) 101 | assert len(data) == 4 102 | for data_set in data: 103 | assert len(data_set) == 30 104 | assert data[2, 8] == 9.95 105 | 106 | def test_find_mode_and_uncertainty(self): 107 | """test finding most probably value and uncertainty from distribution""" 108 | 109 | samples = np.random.normal(0, 1, 10000) 110 | n, bins = np.histogram(samples, bins=100) 111 | mode, error = utils.find_mode_and_uncertainty(n, bins, 0.68) 112 | assert mode == pytest.approx(0, abs=0.5) 113 | assert error == pytest.approx(1, abs=0.5) 114 | 115 | 116 | class TestPrinting: 117 | """Unit tests for the printing sub-module""" 118 | 119 | @pytest.fixture(autouse=True) 120 | def reset_environment(self): 121 | """Before method that resets all configurations""" 122 | sts.get_settings().reset() 123 | 124 | def test_default_print(self): 125 | """Tests the default print format""" 126 | 127 | # Printing in default format 128 | default_printer = printing.get_printer() 129 | assert default_printer(0.0, 0.0) == "0 +/- 0" 130 | assert default_printer(np.inf, 0.0) == "inf +/- inf" 131 | assert default_printer(2, 1) == "2 +/- 1" 132 | assert default_printer(2123, 13) == "2120 +/- 10" 133 | assert default_printer(2.1, 0.5) == "2.1 +/- 0.5" 134 | assert default_printer(2.12, 0.18) == "2.1 +/- 0.2" 135 | 136 | # Printing with significant figure specified for error 137 | sts.set_sig_figs_for_error(2) 138 | assert default_printer(0.0, 0.0) == "0 +/- 0" 139 | assert default_printer(2, 1) == "2.0 +/- 1.0" 140 | assert default_printer(2, 0) == "2.0 +/- 0" 141 | assert default_printer(2.1, 0.5) == "2.10 +/- 0.50" 142 | assert default_printer(2.12, 0.22) == "2.12 +/- 0.22" 143 | assert default_printer(2.123, 0.123) == "2.12 +/- 0.12" 144 | 145 | # Printing with significant figure specified for value 146 | sts.set_sig_figs_for_value(2) 147 | assert default_printer(0.0, 0.0) == "0 +/- 0" 148 | assert default_printer(2, 1) == "2.0 +/- 1.0" 149 | assert default_printer(0, 0.5) == "0.00 +/- 0.50" 150 | assert default_printer(1231, 0.5) == "1200 +/- 0" 151 | assert default_printer(123, 12) == "120 +/- 10" 152 | 153 | def test_scientific_print(self): 154 | """Tests printing in scientific notation""" 155 | 156 | # Printing in default format 157 | scientific_printer = printing.get_printer(sts.PrintStyle.SCIENTIFIC) 158 | assert scientific_printer(0.0, 0.0) == "0 +/- 0" 159 | assert scientific_printer(np.inf, 0.0) == "inf +/- inf" 160 | assert scientific_printer(2.1, 0.5) == "2.1 +/- 0.5" 161 | assert scientific_printer(2.12, 0.18) == "2.1 +/- 0.2" 162 | assert scientific_printer(2123, 13) == "(2.12 +/- 0.01) * 10^3" 163 | assert scientific_printer(0.012312, 0.00334) == "(1.2 +/- 0.3) * 10^-2" 164 | assert scientific_printer(120000, 370) == "(1.200 +/- 0.004) * 10^5" 165 | 166 | # Printing with significant figure specified for error 167 | sts.set_sig_figs_for_error(1) 168 | assert scientific_printer(100, 500) == "(1 +/- 5) * 10^2" 169 | 170 | sts.set_sig_figs_for_error(2) 171 | assert scientific_printer(0.0, 0.0) == "0 +/- 0" 172 | assert scientific_printer(2.1, 0.5) == "2.10 +/- 0.50" 173 | assert scientific_printer(2.12, 0.18) == "2.12 +/- 0.18" 174 | assert scientific_printer(2123, 13) == "(2.123 +/- 0.013) * 10^3" 175 | assert scientific_printer(0.012312, 0.00334) == "(1.23 +/- 0.33) * 10^-2" 176 | assert scientific_printer(120000, 370) == "(1.2000 +/- 0.0037) * 10^5" 177 | 178 | # Printing with significant figure specified for value 179 | sts.set_sig_figs_for_value(2) 180 | assert scientific_printer(0.0, 0.0) == "0 +/- 0" 181 | assert scientific_printer(2.1, 0.5) == "2.1 +/- 0.5" 182 | assert scientific_printer(2.12, 0.18) == "2.1 +/- 0.2" 183 | assert scientific_printer(2123, 13) == "(2.1 +/- 0.0) * 10^3" 184 | assert scientific_printer(0.012312, 0.00334) == "(1.2 +/- 0.3) * 10^-2" 185 | assert scientific_printer(120000, 370) == "(1.2 +/- 0.0) * 10^5" 186 | 187 | def test_latex_print(self): 188 | """Test printing in latex format""" 189 | 190 | latex_printer = printing.get_printer(sts.PrintStyle.LATEX) 191 | 192 | # Printing in default format 193 | assert latex_printer(2.1, 0.5) == r"2.1 \pm 0.5" 194 | assert latex_printer(2.12, 0.18) == r"2.1 \pm 0.2" 195 | assert latex_printer(2123, 13) == r"(2.12 \pm 0.01) * 10^3" 196 | assert latex_printer(0.012312, 0.00334) == r"(1.2 \pm 0.3) * 10^-2" 197 | assert latex_printer(120000, 370) == r"(1.200 \pm 0.004) * 10^5" 198 | 199 | # Printing with significant figure specified for error 200 | sts.set_sig_figs_for_error(2) 201 | assert latex_printer(2.1, 0.5) == r"2.10 \pm 0.50" 202 | assert latex_printer(2.12, 0.18) == r"2.12 \pm 0.18" 203 | assert latex_printer(2123, 13) == r"(2.123 \pm 0.013) * 10^3" 204 | assert latex_printer(0.012312, 0.00334) == r"(1.23 \pm 0.33) * 10^-2" 205 | assert latex_printer(120000, 370) == r"(1.2000 \pm 0.0037) * 10^5" 206 | 207 | # Printing with significant figure specified for value 208 | sts.set_sig_figs_for_value(2) 209 | assert latex_printer(2.1, 0.5) == r"2.1 \pm 0.5" 210 | assert latex_printer(2.12, 0.18) == r"2.1 \pm 0.2" 211 | assert latex_printer(2123, 13) == r"(2.1 \pm 0.0) * 10^3" 212 | assert latex_printer(0.012312, 0.00334) == r"(1.2 \pm 0.3) * 10^-2" 213 | assert latex_printer(120000, 370) == r"(1.2 \pm 0.0) * 10^5" 214 | 215 | 216 | @pytest.fixture() 217 | def resource(): 218 | yield { 219 | "joule": OrderedDict([("kg", 1), ("m", 2), ("s", -2)]), 220 | "pascal": OrderedDict([("kg", 1), ("m", -1), ("s", -2)]), 221 | "coulomb": OrderedDict([("A", 1), ("s", 1)]), 222 | "random-denominator": OrderedDict([("A", -1), ("s", -1)]), 223 | "random-complicated": OrderedDict([ 224 | ("kg", 4), ("m", 2), ("Pa", 1), ("L", -3), ("s", -2), ("A", -2)]) 225 | } 226 | 227 | 228 | class TestUnits: 229 | """Unit tests for the units sub-module""" 230 | 231 | @pytest.fixture(autouse=True) 232 | def reset_environment(self): 233 | sts.get_settings().reset() 234 | units.clear_unit_definitions() 235 | 236 | def test_parse_unit_string(self, resource): 237 | """tests for parsing unit strings into dictionary objects""" 238 | 239 | joule = dict(resource["joule"]) 240 | assert units.parse_unit_string("kg*m^2/s^2") == joule 241 | assert units.parse_unit_string("kg^1m^2s^-2") == joule 242 | 243 | pascal = dict(resource['pascal']) 244 | assert units.parse_unit_string("kg/(m*s^2)") == pascal 245 | assert units.parse_unit_string("kg/m^1s^2") == pascal 246 | assert units.parse_unit_string("kg^1m^-1s^-2") == pascal 247 | 248 | coulomb = dict(resource['coulomb']) 249 | assert units.parse_unit_string("A*s") == coulomb 250 | 251 | denominator = dict(resource['random-denominator']) 252 | assert units.parse_unit_string("A^-1s^-1") == denominator 253 | 254 | complicated = dict(resource['random-complicated']) 255 | assert units.parse_unit_string("kg^4m^2Pa^1L^-3s^-2A^-2") == complicated 256 | assert units.parse_unit_string("kg^4m^2Pa/L^3s^2A^2") == complicated 257 | assert units.parse_unit_string("(kg^4*m^2*Pa)/(L^3*s^2*A^2)") == complicated 258 | 259 | with pytest.raises(ValueError): 260 | units.parse_unit_string("m2kg4/A2") 261 | 262 | def test_construct_unit_string(self, resource): 263 | """tests for building a unit string from a dictionary object""" 264 | 265 | assert units.construct_unit_string(resource['joule']) == "kg⋅m^2⋅s^-2" 266 | assert units.construct_unit_string(resource['pascal']) == "kg⋅m^-1⋅s^-2" 267 | assert units.construct_unit_string(resource['coulomb']) == "A⋅s" 268 | assert units.construct_unit_string(resource['random-denominator']) == "A^-1⋅s^-1" 269 | assert units.construct_unit_string( 270 | resource['random-complicated']) == "kg^4⋅m^2⋅Pa⋅L^-3⋅s^-2⋅A^-2" 271 | 272 | sts.set_unit_style(sts.UnitStyle.FRACTION) 273 | assert units.construct_unit_string(resource['joule']) == "kg⋅m^2/s^2" 274 | assert units.construct_unit_string(resource['pascal']) == "kg/(m⋅s^2)" 275 | assert units.construct_unit_string(resource['coulomb']) == "A⋅s" 276 | assert units.construct_unit_string(resource['random-denominator']) == "1/(A⋅s)" 277 | assert units.construct_unit_string( 278 | resource['random-complicated']) == "kg^4⋅m^2⋅Pa/(L^3⋅s^2⋅A^2)" 279 | 280 | def test_unit_operations(self, resource): 281 | """tests for operating with unit propagation""" 282 | 283 | joule = resource['joule'] 284 | assert units.operate_with_units(lit.NEG, joule) == {'kg': 1, 'm': 2, 's': -2} 285 | assert units.operate_with_units(lit.DIV, {}, joule) == {'kg': -1, 'm': -2, 's': 2} 286 | 287 | pascal = resource['pascal'] 288 | assert units.operate_with_units(lit.ADD, pascal, pascal) == pascal 289 | assert units.operate_with_units(lit.ADD, {}, pascal) == pascal 290 | assert units.operate_with_units(lit.SUB, pascal, pascal) == pascal 291 | assert units.operate_with_units(lit.SUB, {}, pascal) == pascal 292 | 293 | with pytest.warns(UserWarning): 294 | assert units.operate_with_units(lit.ADD, pascal, joule) == {} 295 | 296 | assert units.operate_with_units(lit.MUL, pascal, joule) == {'kg': 2, 'm': 1, 's': -4} 297 | assert units.operate_with_units(lit.DIV, joule, pascal) == {'m': 3} 298 | assert units.operate_with_units(lit.SQRT, joule) == {'kg': 1 / 2, 'm': 1, 's': -1} 299 | 300 | 301 | def test_equivalent_units(self): 302 | """tests for pre-defined compound units""" 303 | 304 | units.define_unit('N', 'kg*m/s^2') 305 | units.define_unit('J', 'N*m') 306 | 307 | a = q.Measurement(5, unit='N') 308 | b = q.Measurement(2, unit='J') 309 | c = q.Measurement(2, unit='m') 310 | 311 | res = a * b / c 312 | assert res.unit == 'N^2' 313 | 314 | res = a / c 315 | q.set_unit_style(q.UnitStyle.FRACTION) 316 | assert res.unit == 'kg/s^2' 317 | --------------------------------------------------------------------------------