├── .github
├── ISSUE_TEMPLATE
│ ├── BUG-REPORT.yml
│ ├── FEATURE-REQUEST.yml
│ └── config.yml
└── workflows
│ ├── build-on-prerelease.yaml
│ ├── build-on-release.yaml
│ ├── test-python-code.yaml
│ └── test-typescript-code.yaml
├── .gitignore
├── LICENSE.md
├── README.md
├── config
├── astronomy_dataset_de421.bsp
├── config.default.json
├── helios.config.default.json
├── tum_enclosure.config.default.json
└── upload.config.default.json
├── logs
├── .gitkeep
├── activity
│ └── .gitkeep
├── archive
│ └── .gitkeep
├── helios-autoexposure
│ └── .gitkeep
├── helios
│ └── .gitkeep
└── tum-enclosure
│ └── .gitkeep
├── packages
├── __init__.py
├── cli
│ ├── __init__.py
│ ├── commands
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── core.py
│ │ ├── logs.py
│ │ ├── remove_filelocks.py
│ │ ├── state.py
│ │ ├── test.py
│ │ └── tum_enclosure.py
│ └── main.py
├── core
│ ├── __init__.py
│ ├── interfaces
│ │ ├── __init__.py
│ │ ├── activity_history.py
│ │ ├── em27_interface.py
│ │ ├── state_interface.py
│ │ └── tum_enclosure_interface.py
│ ├── main.py
│ ├── threads
│ │ ├── __init__.py
│ │ ├── abstract_thread.py
│ │ ├── camtracker_thread.py
│ │ ├── cas_thread.py
│ │ ├── helios_thread.py
│ │ ├── opus_thread.py
│ │ ├── system_monitor_thread.py
│ │ ├── tum_enclosure_thread.py
│ │ └── upload_thread.py
│ ├── types
│ │ ├── __init__.py
│ │ ├── activity_history.py
│ │ ├── config.py
│ │ ├── enclosures
│ │ │ ├── __init__.py
│ │ │ └── tum_enclosure.py
│ │ ├── plc_specification.py
│ │ └── state.py
│ └── utils
│ │ ├── __init__.py
│ │ ├── astronomy.py
│ │ ├── exception_email_client.py
│ │ ├── functions.py
│ │ ├── helios_image_processing.py
│ │ ├── logger.py
│ │ ├── old_helios_image_processing.py
│ │ └── tum_enclosure_logger.py
├── docs
│ ├── .gitignore
│ ├── .node-version
│ ├── babel.config.js
│ ├── bun.lockb
│ ├── docs
│ │ ├── contributor-guide
│ │ │ ├── _category_.json
│ │ │ ├── becoming-a-contributor.mdx
│ │ │ └── code-of-conduct.mdx
│ │ ├── developer-guide
│ │ │ ├── _category_.json
│ │ │ ├── architecture.mdx
│ │ │ ├── external-interfaces.mdx
│ │ │ ├── internal-interfaces.mdx
│ │ │ ├── repository-organization.mdx
│ │ │ ├── running-pyra-manually.mdx
│ │ │ └── testing-and-ci.mdx
│ │ ├── intro
│ │ │ ├── _category_.json
│ │ │ ├── contact.mdx
│ │ │ └── overview.mdx
│ │ └── user-guide
│ │ │ ├── _category_.json
│ │ │ ├── automatic-peak-positioning.mdx
│ │ │ ├── command-line-interface.mdx
│ │ │ ├── faq.mdx
│ │ │ ├── functionality.mdx
│ │ │ ├── measurement-modes.mdx
│ │ │ ├── setup.mdx
│ │ │ ├── tum-enclosure-and-helios.mdx
│ │ │ ├── upload.mdx
│ │ │ └── usage-overview.mdx
│ ├── docusaurus.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── sidebars.js
│ ├── src
│ │ ├── components
│ │ │ ├── github-label.tsx
│ │ │ └── track-on-github.tsx
│ │ ├── css
│ │ │ ├── components
│ │ │ │ └── navbar.scss
│ │ │ ├── custom.scss
│ │ │ └── fonts.css
│ │ └── pages
│ │ │ ├── index.module.css
│ │ │ └── index.tsx
│ ├── static
│ │ ├── .nojekyll
│ │ ├── drawings
│ │ │ ├── exaclidraw-files
│ │ │ │ ├── architecture.excalidraw
│ │ │ │ └── setup.excalidraw
│ │ │ └── exports
│ │ │ │ ├── package-architecture.png
│ │ │ │ ├── setup.png
│ │ │ │ └── state-architecture.png
│ │ ├── fonts
│ │ │ ├── rubik-v26-latin_latin-ext-500.woff
│ │ │ ├── rubik-v26-latin_latin-ext-500.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-500italic.woff
│ │ │ ├── rubik-v26-latin_latin-ext-500italic.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-600.woff
│ │ │ ├── rubik-v26-latin_latin-ext-600.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-600italic.woff
│ │ │ ├── rubik-v26-latin_latin-ext-600italic.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-700.woff
│ │ │ ├── rubik-v26-latin_latin-ext-700.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-700italic.woff
│ │ │ ├── rubik-v26-latin_latin-ext-700italic.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-800.woff
│ │ │ ├── rubik-v26-latin_latin-ext-800.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-800italic.woff
│ │ │ ├── rubik-v26-latin_latin-ext-800italic.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-italic.woff
│ │ │ ├── rubik-v26-latin_latin-ext-italic.woff2
│ │ │ ├── rubik-v26-latin_latin-ext-regular.woff
│ │ │ └── rubik-v26-latin_latin-ext-regular.woff2
│ │ ├── img
│ │ │ ├── docs
│ │ │ │ ├── cli-config-update-example.png
│ │ │ │ ├── config-file-reload.png
│ │ │ │ ├── email-config.png
│ │ │ │ ├── email-error-occured.png
│ │ │ │ ├── email-errors-resolved.png
│ │ │ │ ├── environment-path-manual-1.png
│ │ │ │ ├── environment-path-manual-2.png
│ │ │ │ ├── environment-path-manual-3.png
│ │ │ │ ├── environment-path-manual-4.png
│ │ │ │ ├── helios-example-image-bad-processed.jpg
│ │ │ │ ├── helios-example-image-bad-raw.jpg
│ │ │ │ ├── helios-example-image-good-processed.jpg
│ │ │ │ ├── helios-example-image-good-raw.jpg
│ │ │ │ ├── helios-hardware.png
│ │ │ │ ├── jia-profile-image.jpg
│ │ │ │ ├── logs-tab.png
│ │ │ │ ├── measurement-modes.png
│ │ │ │ ├── moritz-profile-image.jpg
│ │ │ │ ├── muccnet-image-roof.jpg
│ │ │ │ ├── overview-tab.png
│ │ │ │ ├── patrick-profile-image.jpg
│ │ │ │ ├── pyra-cli-path-request.png
│ │ │ │ ├── python-310-path-automatic.png
│ │ │ │ ├── triggers-config.png
│ │ │ │ └── who-made-this-meme.png
│ │ │ ├── favicon.ico
│ │ │ ├── icons
│ │ │ │ └── icon-cheveron-up.svg
│ │ │ └── logo.svg
│ │ └── logo
│ │ │ ├── pyra-4-icon-1024.png
│ │ │ ├── pyra-4-icon-256.png
│ │ │ ├── pyra-4-icon-512.png
│ │ │ └── pyra-4-icon.svg
│ ├── tailwind.config.js
│ └── tsconfig.json
└── ui
│ ├── .env.example
│ ├── .gitignore
│ ├── bun.lockb
│ ├── components.json
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.js
│ ├── src-tauri
│ ├── .gitignore
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── build.rs
│ ├── capabilities
│ │ └── migrated.json
│ ├── gen
│ │ └── schemas
│ │ │ ├── acl-manifests.json
│ │ │ ├── capabilities.json
│ │ │ ├── desktop-schema.json
│ │ │ ├── macOS-schema.json
│ │ │ └── windows-schema.json
│ ├── icons
│ │ ├── 128x128.png
│ │ ├── 128x128@2x.png
│ │ ├── 32x32.png
│ │ ├── Square107x107Logo.png
│ │ ├── Square142x142Logo.png
│ │ ├── Square150x150Logo.png
│ │ ├── Square284x284Logo.png
│ │ ├── Square30x30Logo.png
│ │ ├── Square310x310Logo.png
│ │ ├── Square44x44Logo.png
│ │ ├── Square71x71Logo.png
│ │ ├── Square89x89Logo.png
│ │ ├── StoreLogo.png
│ │ ├── icon.icns
│ │ ├── icon.ico
│ │ └── icon.png
│ ├── src
│ │ └── main.rs
│ └── tauri.conf.json
│ ├── src
│ ├── assets
│ │ ├── green-tum-logo_1024x.icns
│ │ ├── green-tum-logo_1024x.png
│ │ ├── icons.tsx
│ │ └── index.ts
│ ├── components
│ │ ├── configuration
│ │ │ ├── index.ts
│ │ │ ├── rows
│ │ │ │ ├── config-element-line.tsx
│ │ │ │ ├── config-element-note.tsx
│ │ │ │ ├── config-element-text.tsx
│ │ │ │ ├── config-element-time.tsx
│ │ │ │ ├── config-element-toggle.tsx
│ │ │ │ └── labeled-row.tsx
│ │ │ ├── saving-overlay.tsx
│ │ │ └── sections
│ │ │ │ ├── config-section-camtracker.tsx
│ │ │ │ ├── config-section-error-email.tsx
│ │ │ │ ├── config-section-general.tsx
│ │ │ │ ├── config-section-helios.tsx
│ │ │ │ ├── config-section-measurement-triggers.tsx
│ │ │ │ ├── config-section-opus.tsx
│ │ │ │ ├── config-section-tum-enclosure.tsx
│ │ │ │ └── config-section-upload.tsx
│ │ ├── essential
│ │ │ ├── button.tsx
│ │ │ ├── index.ts
│ │ │ ├── live-switch.tsx
│ │ │ ├── log-line.tsx
│ │ │ ├── numeric-button.tsx
│ │ │ ├── ping.tsx
│ │ │ ├── previous-value.tsx
│ │ │ ├── spinner.tsx
│ │ │ ├── text-input.tsx
│ │ │ └── toggle.tsx
│ │ ├── index.ts
│ │ ├── overview
│ │ │ ├── activity-plot.tsx
│ │ │ ├── index.ts
│ │ │ ├── measurement-decision.tsx
│ │ │ ├── pyra-core-status.tsx
│ │ │ └── system-state.tsx
│ │ ├── structural
│ │ │ ├── dashboard.tsx
│ │ │ ├── disconnected-screen.tsx
│ │ │ ├── header.tsx
│ │ │ └── index.ts
│ │ └── ui
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── select.tsx
│ │ │ └── separator.tsx
│ ├── custom-types.ts
│ ├── index.tsx
│ ├── lib
│ │ └── utils.ts
│ ├── main.tsx
│ ├── styles
│ │ └── index.css
│ ├── tabs
│ │ ├── configuration-tab.tsx
│ │ ├── control-tab.tsx
│ │ ├── index.ts
│ │ ├── log-tab.tsx
│ │ └── overview-tab.tsx
│ ├── utils
│ │ ├── fetch-utils
│ │ │ ├── backend.ts
│ │ │ ├── get-file-content.ts
│ │ │ ├── get-project-dir-path.ts
│ │ │ ├── index.ts
│ │ │ └── use-command.ts
│ │ ├── functions.ts
│ │ ├── index.ts
│ │ └── zustand-utils
│ │ │ ├── activity-zustand.ts
│ │ │ ├── config-zustand.ts
│ │ │ ├── core-process-zustand.ts
│ │ │ ├── core-state-zustand.ts
│ │ │ └── logs-zustand.ts
│ └── vite-env.d.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.mjs
├── pdm.lock
├── pyproject.toml
├── run_pyra_core.py
├── scripts
├── run_headless_helios_thread.py
├── run_headless_upload_thread.py
└── sync_version_numbers.py
└── tests
├── __init__.py
├── cli
├── __init__.py
├── test_cli_config.py
└── test_cli_core.py
├── fixtures.py
├── integration
├── __init__.py
├── test_camtracker_connection.py
├── test_config.py
├── test_emailing.py
├── test_opus_connection.py
└── test_uploading.py
├── repository
├── README.md
├── __init__.py
├── test_default_config.py
├── test_static_types.py
└── test_version_numbers.py
└── utils
├── __init__.py
└── test_astronomy.py
/.github/ISSUE_TEMPLATE/BUG-REPORT.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report
3 | title: "[BUG]: "
4 | labels: ["needs-triage"]
5 | assignees:
6 | - dostuffthatmatters
7 | body:
8 | - type: markdown
9 | attributes:
10 | value: |
11 | **🍀 Thanks for taking the time to fill out this bug report!**
12 | - type: textarea
13 | id: description
14 | attributes:
15 | label: Descriptions
16 | description: Describe the bug. Add logs, screenshots, etc. Markdown syntax supported.
17 | validations:
18 | required: true
19 | - type: input
20 | id: pyra-version
21 | attributes:
22 | label: Pyra Version
23 | placeholder: 4.X.Y
24 | validations:
25 | required: true
26 | - type: input
27 | id: opus-version
28 | attributes:
29 | label: OPUS Version
30 | placeholder: 7.8.* / 8.2.* / ...
31 | validations:
32 | required: false
33 | - type: input
34 | id: camtracker-version
35 | attributes:
36 | label: CamTracker Version
37 | placeholder: 3.9.* / ...
38 | validations:
39 | required: false
40 | - type: input
41 | id: windows-version
42 | attributes:
43 | label: Windows Version
44 | placeholder: 10 / ...
45 | validations:
46 | required: false
47 | - type: textarea
48 | id: configuration
49 | attributes:
50 | label: Your `config.json` file
51 | description: This will be automatically formatted into code, so no need for backticks.
52 | render: json
53 | validations:
54 | required: false
55 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Suggest an idea for this project
3 | title: "[FEATURE]: "
4 | labels: ["needs-triage"]
5 | assignees:
6 | - dostuffthatmatters
7 | body:
8 | - type: markdown
9 | attributes:
10 | value: |
11 | **🍀 Thanks for taking the time to submit your feature idea!**
12 | - type: textarea
13 | id: description
14 | attributes:
15 | label: Description
16 | description: Describe your feature idea. Does it solve an existing problem? Feel free to add images or drawings.
17 | validations:
18 | required: true
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links:
3 | - name: Pyra Documentation
4 | url: https://pyra.esm.ei.tum.de/docs
5 | about: Maybe your question is already answered in the documentation?
6 |
--------------------------------------------------------------------------------
/.github/workflows/build-on-prerelease.yaml:
--------------------------------------------------------------------------------
1 | name: "build-on-prerelease"
2 | on:
3 | push:
4 | branches:
5 | - prerelease
6 |
7 | defaults:
8 | run:
9 | working-directory: packages/ui
10 |
11 | jobs:
12 | publish-tauri:
13 | runs-on: windows-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 |
17 | - name: Read version from package.json
18 | id: read_version
19 | uses: actions/github-script@v7
20 | with:
21 | script: |
22 | const fs = require('fs');
23 | const path = 'packages/ui/package.json';
24 | const packageJson = JSON.parse(fs.readFileSync(path, 'utf8'));
25 | core.setOutput('version', packageJson.version);
26 |
27 | - name: Print version number
28 | run: echo "Building UI for PYRA version ${{ steps.read_version.outputs.version }}"
29 |
30 | - name: set up node
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: 20
34 |
35 | - name: set up bun
36 | uses: oven-sh/setup-bun@v2
37 |
38 | - name: set up rust
39 | uses: actions-rust-lang/setup-rust-toolchain@v1
40 | with:
41 | toolchain: stable
42 | cache-workspaces: "./packages/ui/src-tauri -> target"
43 |
44 | - name: tauri build cache
45 | uses: actions/cache@v4
46 | with:
47 | path: packages/ui/src-tauri/target
48 | key: ${{ runner.os }}-tauri-cache
49 |
50 | - name: install dependencies
51 | run: bun install
52 |
53 | - name: build image
54 | run: bun run tauri build
55 |
56 | - name: create draft release
57 | uses: softprops/action-gh-release@v2
58 | with:
59 | tag_name: v${{ steps.read_version.outputs.version }}-prerelease
60 | name: v${{ steps.read_version.outputs.version }}-prerelease (generated by CI)
61 | draft: true
62 | prerelease: true
63 | body: "TODO: description"
64 | target_commitish: prerelease
65 | files: |
66 | packages/ui/src-tauri/target/release/bundle/msi/Pyra UI_${{ steps.read_version.outputs.version }}_x64_en-US.msi
67 | packages/ui/src-tauri/target/release/bundle/nsis/Pyra UI_${{ steps.read_version.outputs.version }}_x64-setup.exe
68 |
--------------------------------------------------------------------------------
/.github/workflows/build-on-release.yaml:
--------------------------------------------------------------------------------
1 | name: "build-on-release"
2 | on:
3 | push:
4 | branches:
5 | - release
6 |
7 | defaults:
8 | run:
9 | working-directory: packages/ui
10 |
11 | jobs:
12 | publish-tauri:
13 | runs-on: windows-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 |
17 | - name: Read version from package.json
18 | id: read_version
19 | uses: actions/github-script@v7
20 | with:
21 | script: |
22 | const fs = require('fs');
23 | const path = 'packages/ui/package.json';
24 | const packageJson = JSON.parse(fs.readFileSync(path, 'utf8'));
25 | core.setOutput('version', packageJson.version);
26 |
27 | - name: Print version number
28 | run: echo "Building UI for PYRA version ${{ steps.read_version.outputs.version }}"
29 |
30 | - name: Check if tag exists
31 | uses: actions/github-script@v7
32 | with:
33 | script: |
34 | const version = '${{ steps.read_version.outputs.version }}';
35 | const tag = `v${version}`;
36 | const { data: tags } = await github.rest.repos.listTags({
37 | owner: context.repo.owner,
38 | repo: context.repo.repo,
39 | });
40 | if (tags.some(t => t.name === tag)) {
41 | core.setFailed(`Tag ${tag} already exists.`);
42 | } else {
43 | console.log(`Tag ${tag} does not exist.`);
44 | }
45 |
46 | - name: set up node
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: 20
50 |
51 | - name: set up bun
52 | uses: oven-sh/setup-bun@v2
53 |
54 | - name: set up rust
55 | uses: actions-rust-lang/setup-rust-toolchain@v1
56 | with:
57 | toolchain: stable
58 | cache-workspaces: "./packages/ui/src-tauri -> target"
59 |
60 | - name: tauri build cache
61 | uses: actions/cache@v4
62 | with:
63 | path: packages/ui/src-tauri/target
64 | key: ${{ runner.os }}-tauri-cache
65 |
66 | - name: install dependencies
67 | run: bun install
68 |
69 | - name: build image
70 | run: bun run tauri build
71 |
72 | - name: create draft release
73 | uses: softprops/action-gh-release@v2
74 | with:
75 | tag_name: v${{ steps.read_version.outputs.version }}
76 | name: v${{ steps.read_version.outputs.version }} (generated by CI)
77 | draft: true
78 | prerelease: true
79 | body: "TODO: description"
80 | target_commitish: release
81 | files: |
82 | packages/ui/src-tauri/target/release/bundle/msi/Pyra UI_${{ steps.read_version.outputs.version }}_x64_en-US.msi
83 | packages/ui/src-tauri/target/release/bundle/nsis/Pyra UI_${{ steps.read_version.outputs.version }}_x64-setup.exe
84 |
--------------------------------------------------------------------------------
/.github/workflows/test-python-code.yaml:
--------------------------------------------------------------------------------
1 | name: "test-python-code"
2 | on:
3 | push:
4 | branches:
5 | - main
6 | paths-ignore:
7 | - "packages/core/ui/**"
8 | - "packages/core/docs/**"
9 | - ".gitignore"
10 | - "README.md"
11 | - "LICENSE.md"
12 | - "netlify.toml"
13 | - ".github/workflows/*.yaml"
14 | - "!.github/workflows/test-python-code.yaml"
15 | pull_request:
16 | branches:
17 | - main
18 | - integration-*
19 | paths-ignore:
20 | - "packages/core/ui/**"
21 | - "packages/core/docs/**"
22 | - ".gitignore"
23 | - "README.md"
24 | - "LICENSE.md"
25 | - "netlify.toml"
26 | - ".github/workflows/*.yaml"
27 | - "!.github/workflows/test-python-code.yaml"
28 | jobs:
29 | test:
30 | runs-on: ubuntu-latest
31 | steps:
32 | - name: Check out repository
33 | uses: actions/checkout@v4
34 | - name: Set up Python 3.10.11
35 | uses: actions/setup-python@v5
36 | with:
37 | python-version: "3.10.11"
38 | cache: "pip"
39 |
40 | - name: Install dependencies
41 | run: pip install ".[dev]"
42 |
43 | - name: Run Pytests
44 | run: |
45 | pytest -m "ci" --cov=packages tests
46 | coverage report
47 |
--------------------------------------------------------------------------------
/.github/workflows/test-typescript-code.yaml:
--------------------------------------------------------------------------------
1 | name: "test-typescript-code"
2 | on:
3 | push:
4 | branches:
5 | - main
6 | paths:
7 | - "packages/ui/**"
8 | - ".github/workflows/test-typescript-code.yaml"
9 | pull_request:
10 | branches:
11 | - main
12 | - integration-*
13 | paths:
14 | - "packages/ui/**"
15 | - ".github/workflows/test-typescript-code.yaml"
16 |
17 | defaults:
18 | run:
19 | working-directory: packages/ui
20 |
21 | jobs:
22 | test:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - name: Check out repository
26 | uses: actions/checkout@v4
27 |
28 | - name: Set up Bun
29 | uses: oven-sh/setup-bun@v2
30 | - name: Set up NodeJS
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: 20
34 |
35 | - name: Install dependencies
36 | run: bun install
37 |
38 | - name: Build frontend
39 | run: bun run build
40 |
--------------------------------------------------------------------------------
/config/astronomy_dataset_de421.bsp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/config/astronomy_dataset_de421.bsp
--------------------------------------------------------------------------------
/config/config.default.json:
--------------------------------------------------------------------------------
1 | {
2 | "general": {
3 | "version": "4.2.2",
4 | "seconds_per_core_iteration": 30,
5 | "test_mode": false,
6 | "station_id": "...",
7 | "min_sun_elevation": 5
8 | },
9 | "opus": {
10 | "em27_ip": "10.10.0.1",
11 | "executable_path": "C:\\Program Files (x86)\\Bruker\\OPUS_7.8.44\\opus.exe",
12 | "experiment_path": "C:\\Users\\Public\\Documents\\Bruker\\OPUS_7.8.44\\XPM\\experiment.xpm",
13 | "macro_path": "C:\\Users\\Public\\Documents\\Bruker\\OPUS_7.8.44\\Macro\\macro.mtx",
14 | "username": "Default",
15 | "password": "...",
16 | "automatic_peak_positioning": false,
17 | "automatic_peak_positioning_dcmin": 0.02,
18 | "interferogram_path": ""
19 | },
20 | "camtracker": {
21 | "config_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\CamTrackerConfig.txt",
22 | "executable_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\CamTracker_3_9.exe",
23 | "working_directory_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0",
24 | "learn_az_elev_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\LEARN_Az_Elev.dat",
25 | "sun_intensity_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\SunIntensity.dat",
26 | "motor_offset_threshold": 10,
27 | "restart_if_logs_are_too_old": false,
28 | "restart_if_cover_remains_closed": false
29 | },
30 | "error_email": {
31 | "smtp_host": "smtp.gmail.com",
32 | "smtp_port": 587,
33 | "smtp_username": "technical-user@domain.com",
34 | "smtp_password": "...",
35 | "sender_address": "technical-user@domain.com",
36 | "notify_recipients": true,
37 | "recipients": "your@mail.com"
38 | },
39 | "measurement_decision": {
40 | "mode": "automatic",
41 | "manual_decision_result": false,
42 | "cli_decision_result": false
43 | },
44 | "measurement_triggers": {
45 | "consider_time": true,
46 | "consider_sun_elevation": true,
47 | "consider_helios": false,
48 | "start_time": {
49 | "hour": 7,
50 | "minute": 0,
51 | "second": 0
52 | },
53 | "stop_time": {
54 | "hour": 21,
55 | "minute": 0,
56 | "second": 0
57 | },
58 | "min_sun_elevation": 0,
59 | "shutdown_grace_period": 300
60 | },
61 | "tum_enclosure": null,
62 | "helios": null,
63 | "upload": null
64 | }
65 |
--------------------------------------------------------------------------------
/config/helios.config.default.json:
--------------------------------------------------------------------------------
1 | {
2 | "camera_id": 0,
3 | "evaluation_size": 15,
4 | "seconds_per_interval": 6,
5 | "min_seconds_between_state_changes": 180,
6 | "edge_pixel_threshold": 1,
7 | "edge_color_threshold": 40,
8 | "target_pixel_brightness": 50,
9 | "save_images_to_archive": false,
10 | "save_current_image": false
11 | }
12 |
--------------------------------------------------------------------------------
/config/tum_enclosure.config.default.json:
--------------------------------------------------------------------------------
1 | {
2 | "ip": "10.0.0.4",
3 | "version": 1,
4 | "controlled_by_user": false
5 | }
6 |
--------------------------------------------------------------------------------
/config/upload.config.default.json:
--------------------------------------------------------------------------------
1 | {
2 | "host": "1.2.3.4",
3 | "user": "...",
4 | "password": "...",
5 | "is_active": true,
6 | "only_upload_at_night": true,
7 | "only_upload_when_not_measuring": true,
8 | "streams": [
9 | {
10 | "is_active": false,
11 | "label": "interferograms",
12 | "variant": "directories",
13 | "dated_regex": "^%Y%m%d$",
14 | "src_directory": "...",
15 | "dst_directory": "...",
16 | "remove_src_after_upload": false
17 | },
18 | {
19 | "is_active": false,
20 | "label": "datalogger",
21 | "variant": "files",
22 | "dated_regex": "^datalogger-%Y-%m-%d*$",
23 | "src_directory": "...",
24 | "dst_directory": "...",
25 | "remove_src_after_upload": false
26 | }
27 | ]
28 | }
--------------------------------------------------------------------------------
/logs/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/.gitkeep
--------------------------------------------------------------------------------
/logs/activity/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/activity/.gitkeep
--------------------------------------------------------------------------------
/logs/archive/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/archive/.gitkeep
--------------------------------------------------------------------------------
/logs/helios-autoexposure/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/helios-autoexposure/.gitkeep
--------------------------------------------------------------------------------
/logs/helios/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/helios/.gitkeep
--------------------------------------------------------------------------------
/logs/tum-enclosure/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/tum-enclosure/.gitkeep
--------------------------------------------------------------------------------
/packages/__init__.py:
--------------------------------------------------------------------------------
1 | from . import cli as cli
2 | from . import core as core
3 |
--------------------------------------------------------------------------------
/packages/cli/__init__.py:
--------------------------------------------------------------------------------
1 | from . import main as main
2 |
--------------------------------------------------------------------------------
/packages/cli/commands/__init__.py:
--------------------------------------------------------------------------------
1 | from .config import config_command_group as config_command_group
2 | from .core import core_command_group as core_command_group
3 | from .logs import logs_command_group as logs_command_group
4 | from .remove_filelocks import remove_filelocks as remove_filelocks
5 | from .state import state_command_group as state_command_group
6 | from .test import test_command_group as test_command_group
7 | from .tum_enclosure import tum_enclosure_command_group as tum_enclosure_command_group
8 |
--------------------------------------------------------------------------------
/packages/cli/commands/remove_filelocks.py:
--------------------------------------------------------------------------------
1 | """Remove all filelocks. Helpful when any of the programs crashed during
2 | writing to a file. Normally, this should not be necessary."""
3 |
4 | import os
5 |
6 | import click
7 |
8 | from packages.core import interfaces, utils
9 |
10 | _dir = os.path.dirname
11 | _PROJECT_DIR = _dir(_dir(_dir(_dir(os.path.abspath(__file__)))))
12 | logger = utils.Logger(origin="cli")
13 |
14 |
15 | def _print_green(text: str) -> None:
16 | click.echo(click.style(text, fg="green"))
17 |
18 |
19 | @click.command(
20 | name="remove-filelocks",
21 | help="Remove all filelocks. Helpful when any of the programs crashed during writing to a file. Normally, this should not be necessary.",
22 | )
23 | def remove_filelocks() -> None:
24 | with interfaces.StateInterface.update_state() as s:
25 | s.activity.cli_calls += 1
26 | logger.debug('running command "remove-filelocks"')
27 | lock_files = [
28 | os.path.join(_PROJECT_DIR, "config", ".config.lock"),
29 | os.path.join(_PROJECT_DIR, "config", ".state.lock"),
30 | os.path.join(_PROJECT_DIR, "logs", ".logs.lock"),
31 | ]
32 | for f in lock_files:
33 | if os.path.isfile(f):
34 | os.remove(f)
35 | _print_green(f"Removing {f}")
36 | _print_green("Done!")
37 |
--------------------------------------------------------------------------------
/packages/cli/commands/state.py:
--------------------------------------------------------------------------------
1 | """Read the current state.json file."""
2 |
3 | import click
4 |
5 | from packages.core import interfaces, utils
6 |
7 | logger = utils.Logger(origin="cli")
8 |
9 |
10 | @click.group()
11 | def state_command_group() -> None:
12 | pass
13 |
14 |
15 | @state_command_group.command(name="get", help="Read the current state.json file.")
16 | @click.option("--indent", is_flag=True, help="Print the JSON in an indented manner")
17 | def _get_state(indent: bool) -> None:
18 | logger.debug('running command "state get"')
19 | state = interfaces.StateInterface.load_state()
20 | click.echo(state.model_dump_json(indent=(2 if indent else None)))
21 |
--------------------------------------------------------------------------------
/packages/cli/commands/test.py:
--------------------------------------------------------------------------------
1 | import circadian_scp_upload
2 | import click
3 | import fabric.runners
4 |
5 | from packages.core import interfaces, threads, types, utils
6 |
7 | logger = utils.Logger(origin="cli")
8 |
9 |
10 | def _print_green(text: str) -> None:
11 | click.echo(click.style(text, fg="green"))
12 |
13 |
14 | def _print_red(text: str) -> None:
15 | click.echo(click.style(text, fg="red"))
16 |
17 |
18 | @click.group()
19 | def test_command_group() -> None:
20 | pass
21 |
22 |
23 | @test_command_group.command(name="opus")
24 | def _test_opus() -> None:
25 | """Start OPUS, run a macro, stop the macro, close opus."""
26 | with interfaces.StateInterface.update_state() as s:
27 | s.activity.cli_calls += 1
28 | logger.info('running command "test opus"')
29 | config = types.Config.load()
30 | try:
31 | threads.OpusThread.test_setup(config, logger)
32 | finally:
33 | threads.opus_thread.OpusProgram.stop(logger)
34 | _print_green("Successfully tested opus connection.")
35 |
36 |
37 | @test_command_group.command(name="camtracker")
38 | def _test_camtracker() -> None:
39 | """Start CamTracker, check if it is running, stop CamTracker."""
40 | with interfaces.StateInterface.update_state() as s:
41 | s.activity.cli_calls += 1
42 | logger.info('running command "test camtracker"')
43 | config = types.Config.load()
44 | threads.camtracker_thread.CamTrackerThread.test_setup(config, logger)
45 | _print_green("Successfully tested CamTracker connection.")
46 |
47 |
48 | @test_command_group.command(name="email")
49 | def _test_emailing() -> None:
50 | """Send a test email."""
51 | with interfaces.StateInterface.update_state() as s:
52 | s.activity.cli_calls += 1
53 | logger.info('running command "test email"')
54 | config = types.Config.load()
55 | utils.ExceptionEmailClient.send_test_email(config)
56 | _print_green("Successfully sent test email.")
57 |
58 |
59 | @test_command_group.command(name="upload")
60 | def _test_uploading() -> None:
61 | """try to connect to upload server."""
62 | with interfaces.StateInterface.update_state() as s:
63 | s.activity.cli_calls += 1
64 | logger.info('running command "test upload"')
65 | config = types.Config.load()
66 | if config.upload is None:
67 | _print_red("No upload server configured.")
68 | return
69 |
70 | with circadian_scp_upload.RemoteConnection(
71 | config.upload.host.root,
72 | config.upload.user,
73 | config.upload.password,
74 | ) as remote_connection:
75 | if remote_connection.connection.is_connected:
76 | _print_green("Successfully connected to upload server")
77 | else:
78 | _print_red("Could not connect to upload server")
79 | exit(1)
80 |
81 | result: fabric.runners.Result
82 |
83 | try:
84 | result = remote_connection.connection.run("ls ~ > /dev/null 2>&1")
85 | if result.return_code == 0:
86 | _print_green("Found home directory of upload user account")
87 | else:
88 | raise
89 | except Exception as e:
90 | logger.debug(f"Exception: {e}")
91 | _print_red(
92 | "Upload user account does not have a home directory, " + 'command "ls ~" failed'
93 | )
94 | exit(1)
95 |
96 | try:
97 | result = remote_connection.connection.run("python3.10 --version > /dev/null 2>&1")
98 | if result.return_code == 0:
99 | _print_green("Found Python3.10 installation on upload server")
100 | else:
101 | raise
102 | except Exception as e:
103 | logger.debug(f"Exception: {e}")
104 | _print_red(
105 | "Python3.10 is not installed on upload server, "
106 | + 'command "python3.10 --version" failed'
107 | )
108 | exit(1)
109 |
--------------------------------------------------------------------------------
/packages/cli/main.py:
--------------------------------------------------------------------------------
1 | """Pyra CLI entry point. Use `pyra-cli --help`/`pyra-cli $command --help`
2 | to see all available commands.`"""
3 |
4 | import sys
5 |
6 | import click
7 | import tum_esm_utils
8 |
9 | _PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3)
10 | sys.path.append(_PROJECT_DIR)
11 |
12 | from packages.cli.commands import (
13 | config_command_group,
14 | core_command_group,
15 | logs_command_group,
16 | remove_filelocks,
17 | state_command_group,
18 | test_command_group,
19 | tum_enclosure_command_group,
20 | )
21 | from packages.core import utils
22 |
23 | logger = utils.Logger(origin="cli")
24 |
25 |
26 | @click.command(help="Print Pyra version and code directory path.")
27 | def print_cli_information() -> None:
28 | logger.debug('running command "info"')
29 | click.echo(
30 | click.style(
31 | f'This CLI is running Pyra version 4.2.2 in directory "{_PROJECT_DIR}"',
32 | fg="green",
33 | )
34 | )
35 |
36 |
37 | @click.group()
38 | def cli() -> None:
39 | pass
40 |
41 |
42 | cli.add_command(print_cli_information, name="info")
43 | cli.add_command(config_command_group, name="config")
44 | cli.add_command(core_command_group, name="core")
45 | cli.add_command(logs_command_group, name="logs")
46 | cli.add_command(tum_enclosure_command_group, name="tum-enclosure")
47 | cli.add_command(remove_filelocks, name="remove-filelocks")
48 | cli.add_command(state_command_group, name="state")
49 | cli.add_command(test_command_group, name="test")
50 |
51 | if __name__ == "__main__":
52 | cli.main(prog_name="pyra-cli")
53 |
--------------------------------------------------------------------------------
/packages/core/__init__.py:
--------------------------------------------------------------------------------
1 | from . import interfaces as interfaces
2 | from . import main as main
3 | from . import threads as threads
4 | from . import types as types
5 | from . import utils as utils
6 |
--------------------------------------------------------------------------------
/packages/core/interfaces/__init__.py:
--------------------------------------------------------------------------------
1 | from .activity_history import ActivityHistoryInterface as ActivityHistoryInterface
2 | from .em27_interface import EM27Interface as EM27Interface
3 | from .state_interface import StateInterface as StateInterface
4 | from .tum_enclosure_interface import TUMEnclosureInterface as TUMEnclosureInterface
5 |
--------------------------------------------------------------------------------
/packages/core/interfaces/activity_history.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import datetime
3 | import os
4 | import time
5 | from typing import Optional
6 | import tum_esm_utils
7 | from packages.core import types, utils
8 |
9 | _PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=4)
10 | _MINUTES_BETWEEN_DUMPS = 2
11 |
12 |
13 | class ActivityHistoryInterface:
14 | def __init__(self, logger: utils.Logger) -> None:
15 | self.last_dump_time = time.time()
16 | self.logger = logger
17 | self.activity_history: Optional[types.ActivityHistory] = None
18 |
19 | def get(self) -> tuple[types.ActivityHistory, int]:
20 | """Return the current activity history and the current minute index.
21 |
22 | Creates a new file if it doesn't exist yet."""
23 |
24 | now = datetime.datetime.now()
25 | minute_index = now.hour * 60 + now.minute
26 |
27 | # if loaded, return if same date otherwise dump and unload
28 | if self.activity_history is not None:
29 | if now.date() == self.activity_history.date:
30 | return self.activity_history, minute_index
31 | else:
32 | ActivityHistoryInterface._dump(self.activity_history)
33 | self.activity_history = None
34 |
35 | # -> a new file has to be loaded/initialized
36 |
37 | path = ActivityHistoryInterface._filepath(now.date())
38 | if os.path.exists(path):
39 | try:
40 | return types.ActivityHistory.model_validate_json(
41 | tum_esm_utils.files.load_file(path)
42 | ), minute_index
43 | except Exception as e:
44 | self.logger.warning(f"Could not load existing activity history file at {path}: {e}")
45 |
46 | self.logger.info(f"Creating new activity history file at {path}")
47 | new_ah = types.ActivityHistory(date=now.date())
48 | ActivityHistoryInterface._dump(new_ah)
49 | self.activity_history = new_ah
50 | return new_ah, minute_index
51 |
52 | def update(self, ah: types.ActivityHistory) -> None:
53 | """Update the activity history and dump it to the file system."""
54 | self.activity_history = ah
55 | if (time.time() - self.last_dump_time) >= (_MINUTES_BETWEEN_DUMPS * 60):
56 | ActivityHistoryInterface._dump(ah)
57 |
58 | def flush(self) -> None:
59 | """Dump the current activity history to the file system."""
60 |
61 | ah = self.activity_history
62 | if ah is not None:
63 | tum_esm_utils.files.dump_file(
64 | ActivityHistoryInterface._filepath(ah.date), ah.model_dump_json()
65 | )
66 |
67 | @staticmethod
68 | def _filepath(date: datetime.date) -> str:
69 | return os.path.join(
70 | _PROJECT_DIR, "logs", "activity", f"activity-{date.strftime('%Y-%m-%d')}.json"
71 | )
72 |
73 | @staticmethod
74 | def _dump(ah: types.ActivityHistory) -> None:
75 | tum_esm_utils.files.dump_file(
76 | ActivityHistoryInterface._filepath(ah.date), ah.model_dump_json()
77 | )
78 |
--------------------------------------------------------------------------------
/packages/core/interfaces/em27_interface.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import re
3 | from typing import Optional
4 | import tum_esm_utils
5 | import requests
6 | from packages.core import utils
7 |
8 |
9 | class EM27Interface:
10 | """Communicate with the EM27 directly via the HTML interface"""
11 |
12 | @staticmethod
13 | def get_peak_position(ip: tum_esm_utils.validators.StrictIPv4Adress) -> Optional[int]:
14 | """Get the peak position of the EM27.
15 |
16 | This reads the ABP value from the EM27 via http://{ip}/config/servmenuA.htm"""
17 | body = EM27Interface._get_html(ip, "/config/servmenuA.htm")
18 | if body is None:
19 | return None
20 | r: list[str] = re.findall(r' None:
30 | """Set the peak position of the EM27.
31 |
32 | It is equivalent to setting the ABP via http://{ip}/config/servmenuA.htm"""
33 | try:
34 | requests.get(
35 | f"http://{ip.root}/config/servmenuA.htm?sub=Send^&ABP={new_peak_position}^",
36 | timeout=3,
37 | )
38 | except Exception as e:
39 | raise RuntimeError(f"Could not set peak position") from e
40 |
41 | @staticmethod
42 | def get_last_powerup_timestamp(
43 | ip: tum_esm_utils.validators.StrictIPv4Adress,
44 | ) -> Optional[float]:
45 | """Get the peak position of the EM27.
46 |
47 | This reads the ABP value from the EM27 via http://{ip}/config/cfg_ctrler.htm"""
48 | body = EM27Interface._get_html(ip, "/config/cfg_ctrler.htm")
49 | if body is None:
50 | return None
51 | r: list[str] = re.findall(r"
([^<]+) ", body)
52 | if len(r) != 1:
53 | return None
54 |
55 | dt = utils.parse_verbal_timedelta_string(r[0])
56 | last_powerup_time = datetime.datetime.now() - dt
57 | return last_powerup_time.timestamp()
58 |
59 | @staticmethod
60 | def _get_html(
61 | ip: tum_esm_utils.validators.StrictIPv4Adress,
62 | url: str,
63 | ) -> Optional[str]:
64 | """Fetches a HTML page from the EM27: http://{ip}{url}"""
65 | try:
66 | raw_body = requests.get(f"http://{ip.root}{url}", timeout=3)
67 | except Exception:
68 | return None
69 | body = raw_body.text.replace("\n", " ").replace("\t", " ").lower()
70 | while " " in body:
71 | body = body.replace(" ", " ")
72 | return body
73 |
--------------------------------------------------------------------------------
/packages/core/interfaces/state_interface.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 | import datetime
5 | import os
6 | from typing import Generator
7 |
8 | import pydantic
9 | import tum_esm_utils
10 |
11 | from packages.core import types, utils
12 |
13 | _dir = os.path.dirname
14 | _PROJECT_DIR = _dir(_dir(_dir(_dir(os.path.abspath(__file__)))))
15 | _STATE_LOCK_PATH = os.path.join(_PROJECT_DIR, "logs", ".state.lock")
16 | _STATE_FILE_PATH = os.path.join(_PROJECT_DIR, "logs", "state.json")
17 |
18 | logger = utils.Logger(origin="state")
19 |
20 |
21 | class StateInterface:
22 | @staticmethod
23 | @tum_esm_utils.decorators.with_filelock(lockfile_path=_STATE_LOCK_PATH, timeout=5)
24 | def load_state() -> types.StateObject:
25 | """Load the state from the state file."""
26 |
27 | return StateInterface._load_state_without_filelock()
28 |
29 | @staticmethod
30 | def _load_state_without_filelock() -> types.StateObject:
31 | """Load the state from the state file."""
32 |
33 | try:
34 | with open(_STATE_FILE_PATH, "r") as f:
35 | state = types.StateObject.model_validate_json(f.read())
36 | except (
37 | FileNotFoundError,
38 | pydantic.ValidationError,
39 | UnicodeDecodeError,
40 | ) as e:
41 | logger.warning(f"Could not load state file - Creating new one: {e}")
42 | state = types.StateObject(last_updated=datetime.datetime.now())
43 | with open(_STATE_FILE_PATH, "w") as f:
44 | f.write(state.model_dump_json(indent=4))
45 | return state
46 |
47 | @staticmethod
48 | @contextlib.contextmanager
49 | @tum_esm_utils.decorators.with_filelock(lockfile_path=_STATE_LOCK_PATH, timeout=5)
50 | def update_state() -> Generator[types.StateObject, None, None]:
51 | """Update the state file in a context manager.
52 |
53 | Example:
54 |
55 | ```python
56 | with interfaces.StateInterface.update_state() as state:
57 | state.helios_indicates_good_conditions = "yes"
58 | ```
59 |
60 | The file will be locked correctly, so that no other process can
61 | interfere with the state file and the state
62 | """
63 |
64 | state = StateInterface._load_state_without_filelock()
65 | state_before = state.model_copy(deep=True)
66 |
67 | yield state
68 |
69 | if state != state_before:
70 | state.last_updated = datetime.datetime.now()
71 | with open(_STATE_FILE_PATH, "w") as f:
72 | f.write(state.model_dump_json(indent=4))
73 |
--------------------------------------------------------------------------------
/packages/core/threads/__init__.py:
--------------------------------------------------------------------------------
1 | from . import abstract_thread as abstract_thread
2 | from .camtracker_thread import CamTrackerThread as CamTrackerThread
3 | from .cas_thread import CASThread as CASThread
4 | from .helios_thread import HeliosThread as HeliosThread
5 | from .opus_thread import OpusThread as OpusThread
6 | from .system_monitor_thread import SystemMonitorThread as SystemMonitorThread
7 | from .tum_enclosure_thread import TUMEnclosureThread as TUMEnclosureThread
8 | from .upload_thread import UploadThread as UploadThread
9 |
--------------------------------------------------------------------------------
/packages/core/threads/abstract_thread.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import threading
3 | import time
4 | from typing import Optional
5 |
6 | from packages.core import types, utils
7 |
8 |
9 | class AbstractThread(abc.ABC):
10 | """Abstract base class for all threads"""
11 |
12 | logger_origin: Optional[str] = None
13 |
14 | def __init__(self) -> None:
15 | """Initialize the thread instance. This does not start the
16 | thread but only initializes the instance that triggers the
17 | thread to start and stop correctly."""
18 |
19 | assert self.__class__.logger_origin is not None
20 | self.logger: utils.Logger = utils.Logger(origin=self.__class__.logger_origin)
21 | self.thread = self.get_new_thread_object()
22 | self.thread_start_time: Optional[float] = None
23 |
24 | def update_thread_state(self, config: types.Config) -> bool:
25 | """Use `self.should_be_running` to determine if the thread
26 | should be running or not. If it should be running and it is
27 | not running, start the thread. If it should not be running
28 | and it is running, stop the thread.
29 |
30 | Returns True if the thread is running/pausing correctly, False
31 | otherwise."""
32 |
33 | should_be_running: bool = self.__class__.should_be_running(config)
34 |
35 | if should_be_running:
36 | if self.thread_start_time is not None:
37 | if self.thread.is_alive():
38 | self.logger.debug("Thread is running correctly")
39 | return True
40 | else:
41 | now = time.time()
42 | if (self.thread_start_time - now) <= 43199:
43 | self.logger.debug("Thread has crashed/stopped, running teardown")
44 | self.thread.join()
45 | self.thread_start_time = None
46 | # set up a new thread instance for the next time the thread should start
47 | self.thread = self.get_new_thread_object()
48 | else:
49 | self.logger.debug("Starting the thread")
50 | self.thread.start()
51 | self.thread_start_time = time.time()
52 |
53 | else:
54 | if self.thread_start_time is not None:
55 | self.logger.debug("Joining the thread")
56 | self.thread.join()
57 | self.thread = self.get_new_thread_object()
58 | self.thread_start_time = None
59 | else:
60 | self.logger.debug("Thread is pausing")
61 | return True
62 |
63 | return False
64 |
65 | @staticmethod
66 | @abc.abstractmethod
67 | def should_be_running(config: types.Config) -> bool:
68 | """Based on the config, should the thread be running or not?"""
69 |
70 | @staticmethod
71 | @abc.abstractmethod
72 | def get_new_thread_object() -> threading.Thread:
73 | """Return a new thread object that is to be started."""
74 |
75 | @staticmethod
76 | @abc.abstractmethod
77 | def main(headless: bool = False) -> None:
78 | """Main entrypoint of the thread. In headless mode,
79 | don't write to log files but print to console."""
80 |
--------------------------------------------------------------------------------
/packages/core/types/__init__.py:
--------------------------------------------------------------------------------
1 | from .activity_history import ActivityHistory as ActivityHistory
2 | from .enclosures import tum_enclosure as tum_enclosure
3 | from .plc_specification import PLCSpecification as PLCSpecification
4 | from .plc_specification import PLCSpecificationActors as PLCSpecificationActors
5 | from .plc_specification import PLCSpecificationConnections as PLCSpecificationConnections
6 | from .plc_specification import PLCSpecificationControl as PLCSpecificationControl
7 | from .plc_specification import PLCSpecificationPower as PLCSpecificationPower
8 | from .plc_specification import PLCSpecificationSensors as PLCSpecificationSensors
9 | from .plc_specification import PLCSpecificationState as PLCSpecificationState
10 |
11 | from .config import Config as Config
12 | from .config import PartialConfig as PartialConfig
13 |
14 | from .state import ExceptionStateItem as ExceptionStateItem
15 | from .state import OperatingSystemState as OperatingSystemState
16 | from .state import Position as Position
17 | from .state import StateObject as StateObject
18 |
--------------------------------------------------------------------------------
/packages/core/types/activity_history.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import pydantic
3 |
4 |
5 | class ActivityHistory(pydantic.BaseModel):
6 | date: datetime.date
7 | is_running: list[int] = [0] * 24 * 60
8 | is_measuring: list[int] = [0] * 24 * 60
9 | has_errors: list[int] = [0] * 24 * 60
10 | camtracker_startups: list[int] = [0] * 24 * 60
11 | opus_startups: list[int] = [0] * 24 * 60
12 | cli_calls: list[int] = [0] * 24 * 60
13 | is_uploading: list[int] = [0] * 24 * 60
14 |
--------------------------------------------------------------------------------
/packages/core/types/enclosures/__init__.py:
--------------------------------------------------------------------------------
1 | from . import tum_enclosure as tum_enclosure
2 |
--------------------------------------------------------------------------------
/packages/core/types/enclosures/tum_enclosure.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import Literal, Optional
3 |
4 | from tum_esm_utils.validators import StricterBaseModel, StrictIPv4Adress
5 |
6 | # --- CONFIG ---
7 |
8 |
9 | class TUMEnclosureConfig(StricterBaseModel):
10 | ip: StrictIPv4Adress
11 | version: Literal[1, 2]
12 | controlled_by_user: bool
13 |
14 |
15 | class PartialTUMEnclosureConfig(StricterBaseModel):
16 | """Like `TUMEnclosureConfig`, but all fields are optional."""
17 |
18 | ip: Optional[StrictIPv4Adress] = None
19 | version: Optional[Literal[1, 2]] = None
20 | controlled_by_user: Optional[bool] = None
21 |
22 |
23 | # --- STATE ---
24 |
25 |
26 | class ActorsState(StricterBaseModel):
27 | fan_speed: Optional[int] = None
28 | current_angle: Optional[int] = None
29 |
30 |
31 | class ControlState(StricterBaseModel):
32 | auto_temp_mode: Optional[bool] = None
33 | manual_control: Optional[bool] = None
34 | manual_temp_mode: Optional[bool] = None
35 | sync_to_tracker: Optional[bool] = None
36 |
37 |
38 | class SensorsState(StricterBaseModel):
39 | humidity: Optional[int] = None
40 | temperature: Optional[int] = None
41 |
42 |
43 | class StateState(StricterBaseModel):
44 | cover_closed: Optional[bool] = None
45 | motor_failed: Optional[bool] = None
46 | rain: Optional[bool] = None
47 | reset_needed: Optional[bool] = None
48 | ups_alert: Optional[bool] = None
49 |
50 |
51 | class PowerState(StricterBaseModel):
52 | camera: Optional[bool] = None
53 | computer: Optional[bool] = None
54 | heater: Optional[bool] = None
55 | router: Optional[bool] = None
56 | spectrometer: Optional[bool] = None
57 |
58 |
59 | class ConnectionsState(StricterBaseModel):
60 | camera: Optional[bool] = None
61 | computer: Optional[bool] = None
62 | heater: Optional[bool] = None
63 | router: Optional[bool] = None
64 | spectrometer: Optional[bool] = None
65 |
66 |
67 | class TUMEnclosureState(StricterBaseModel):
68 | last_full_fetch: Optional[datetime.datetime] = None
69 | actors: ActorsState = ActorsState()
70 | control: ControlState = ControlState()
71 | sensors: SensorsState = SensorsState()
72 | state: StateState = StateState()
73 | power: PowerState = PowerState()
74 | connections: ConnectionsState = ConnectionsState()
75 |
--------------------------------------------------------------------------------
/packages/core/types/plc_specification.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import pydantic
4 |
5 |
6 | class PLCSpecificationActors(pydantic.BaseModel):
7 | current_angle: tuple[int, int, int]
8 | fan_speed: tuple[int, int, int]
9 | move_cover: tuple[int, int, int]
10 | nominal_angle: tuple[int, int, int]
11 |
12 |
13 | class PLCSpecificationControl(pydantic.BaseModel):
14 | auto_temp_mode: tuple[int, int, int, int]
15 | manual_control: tuple[int, int, int, int]
16 | manual_temp_mode: tuple[int, int, int, int]
17 | reset: tuple[int, int, int, int]
18 | sync_to_tracker: tuple[int, int, int, int]
19 |
20 |
21 | class PLCSpecificationSensors(pydantic.BaseModel):
22 | humidity: tuple[int, int, int]
23 | temperature: tuple[int, int, int]
24 |
25 |
26 | class PLCSpecificationState(pydantic.BaseModel):
27 | cover_closed: tuple[int, int, int, int]
28 | motor_failed: Optional[tuple[int, int, int, int]]
29 | rain: tuple[int, int, int, int]
30 | reset_needed: tuple[int, int, int, int]
31 | ups_alert: tuple[int, int, int, int]
32 |
33 |
34 | class PLCSpecificationPower(pydantic.BaseModel):
35 | camera: tuple[int, int, int, int]
36 | computer: Optional[tuple[int, int, int, int]]
37 | heater: tuple[int, int, int, int]
38 | router: Optional[tuple[int, int, int, int]]
39 | spectrometer: tuple[int, int, int, int]
40 |
41 |
42 | class PLCSpecificationConnections(pydantic.BaseModel):
43 | camera: Optional[tuple[int, int, int, int]]
44 | computer: tuple[int, int, int, int]
45 | heater: tuple[int, int, int, int]
46 | router: tuple[int, int, int, int]
47 | spectrometer: Optional[tuple[int, int, int, int]]
48 |
49 |
50 | class PLCSpecification(pydantic.BaseModel):
51 | actors: PLCSpecificationActors
52 | control: PLCSpecificationControl
53 | sensors: PLCSpecificationSensors
54 | state: PLCSpecificationState
55 | power: PLCSpecificationPower
56 | connections: PLCSpecificationConnections
57 |
--------------------------------------------------------------------------------
/packages/core/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .astronomy import Astronomy as Astronomy
2 | from .exception_email_client import ExceptionEmailClient as ExceptionEmailClient
3 | from .functions import read_last_file_line as read_last_file_line
4 | from .functions import find_most_recent_files as find_most_recent_files
5 | from .functions import parse_verbal_timedelta_string as parse_verbal_timedelta_string
6 | from .helios_image_processing import HeliosImageProcessing as HeliosImageProcessing
7 | from .old_helios_image_processing import OldHeliosImageProcessing as OldHeliosImageProcessing
8 | from .logger import Logger as Logger
9 | from .tum_enclosure_logger import TUMEnclosureLogger as TUMEnclosureLogger
10 |
--------------------------------------------------------------------------------
/packages/core/utils/astronomy.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | from typing import Any, Optional
4 |
5 | import skyfield.api
6 | import tum_esm_utils
7 |
8 | from packages.core import types
9 |
10 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=4)
11 |
12 |
13 | class Astronomy:
14 | _PLANETS: Any = None
15 |
16 | @staticmethod
17 | def load_astronomical_dataset() -> None:
18 | """Loads the astronomical dataset DE421 from the NASA JPL website,
19 | see https://ssd.jpl.nasa.gov/planets/eph_export.html."""
20 |
21 | filepath = os.path.join(PROJECT_DIR, "config", "astronomy_dataset_de421.bsp")
22 | assert os.path.isfile(filepath), "Astronomical dataset not found"
23 |
24 | if Astronomy._PLANETS is None:
25 | Astronomy._PLANETS = skyfield.api.load_file(filepath)
26 |
27 | @staticmethod
28 | def get_current_sun_elevation(
29 | config: types.Config,
30 | lat: Optional[float] = None,
31 | lon: Optional[float] = None,
32 | alt: Optional[float] = None,
33 | datetime_object: Optional[datetime.datetime] = None,
34 | ) -> float:
35 | """Computes current sun elevation in degree, based on the
36 | coordinates from the CamTracker config file."""
37 |
38 | assert Astronomy._PLANETS is not None, "Astronomical dataset not loaded"
39 | earth = Astronomy._PLANETS["Earth"]
40 | sun = Astronomy._PLANETS["Sun"]
41 |
42 | if datetime_object is not None:
43 | current_timestamp = datetime_object.timestamp() # type: ignore
44 | assert isinstance(current_timestamp, float)
45 | current_time = skyfield.api.load.timescale().from_datetime(
46 | datetime.datetime.fromtimestamp(current_timestamp, tz=skyfield.api.utc)
47 | )
48 | else:
49 | current_time = skyfield.api.load.timescale().now()
50 |
51 | if (lat is None) or (lon is None) or (alt is None):
52 | lat, lon, alt = Astronomy.get_camtracker_coordinates(config)
53 |
54 | current_position = earth + skyfield.api.wgs84.latlon(
55 | latitude_degrees=lat,
56 | longitude_degrees=lon,
57 | elevation_m=alt,
58 | )
59 |
60 | sun_pos = current_position.at(current_time).observe(sun).apparent()
61 | altitude, _, _ = sun_pos.altaz()
62 | return round(float(altitude.degrees), 3)
63 |
64 | @staticmethod
65 | def get_camtracker_coordinates(config: types.Config) -> tuple[float, float, float]:
66 | """Returns the coordinates from the CamTracker config file as (lat, lon, alt)."""
67 |
68 | if config.general.test_mode:
69 | return (48.151, 11.569, 539) # TUM_I location in munich
70 |
71 | try:
72 | with open(config.camtracker.config_path.root, "r") as f:
73 | _lines = f.readlines()
74 | _marker_line_index: Optional[int] = None
75 | for n, line in enumerate(_lines):
76 | if line == "$1\n":
77 | _marker_line_index = n
78 | assert _marker_line_index is not None, "Could not find $1 marker"
79 | lat = float(_lines[_marker_line_index + 1].strip())
80 | lon = float(_lines[_marker_line_index + 2].strip())
81 | alt = float(_lines[_marker_line_index + 3].strip()) * 1000
82 | return lat, lon, alt
83 | except Exception as e:
84 | raise Exception(
85 | "Could not read CamTracker config file. Please make sure that "
86 | f"the config located at {config.camtracker.config_path.root} is "
87 | "valid or change the path in the config."
88 | ) from e
89 |
--------------------------------------------------------------------------------
/packages/core/utils/functions.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import glob
3 | import os
4 | import time
5 | from typing import Literal
6 |
7 |
8 | def read_last_file_line(
9 | file_path: str,
10 | ignore_trailing_whitespace: bool = True,
11 | ) -> str:
12 | """Reads the last non empty line of a file"""
13 |
14 | with open(file_path, "rb") as f:
15 | f.seek(-1, os.SEEK_END)
16 |
17 | if ignore_trailing_whitespace:
18 | while f.read(1) in [b"\n", b" "]:
19 | try:
20 | f.seek(-2, os.SEEK_CUR)
21 | except OSError:
22 | # reached the beginning of the file
23 | return ""
24 |
25 | f.seek(-1, os.SEEK_CUR)
26 | # now the cursor is right before the last
27 | # character that is not a newline or a space
28 |
29 | last_line: bytes = b""
30 | new_character: bytes = b""
31 | while True:
32 | new_character = f.read(1)
33 | if new_character == b"\n":
34 | break
35 | last_line += new_character
36 | f.seek(-2, os.SEEK_CUR)
37 |
38 | return last_line.decode().strip()[::-1]
39 |
40 |
41 | def find_most_recent_files(
42 | directory_path: str,
43 | time_limit: float,
44 | time_indicator: Literal["created", "modified"],
45 | ) -> list[str]:
46 | """Find the most recently modified files in a directory.
47 |
48 | Args:
49 | directory_path: The path to the directory to search.
50 | time_limit: The time limit in seconds.
51 |
52 | Returns:
53 | A list of the most recently modified absolute filepaths sorted by
54 | modification time (the most recent first) and only including files
55 | modified within the time limit.
56 | """
57 | if time_limit <= 0:
58 | return []
59 |
60 | current_timestamp = time.time()
61 | files = [f for f in glob.glob(os.path.join(directory_path, "*")) if os.path.isfile(f)]
62 | modification_times = [os.path.getmtime(f) for f in files]
63 | creation_times = [os.path.getctime(f) for f in files]
64 | times = modification_times if time_indicator == "modified" else creation_times
65 | merged = sorted(
66 | [(f, t) for f, t in list(zip(files, times)) if t >= (current_timestamp - time_limit)],
67 | key=lambda x: x[1],
68 | reverse=True,
69 | )
70 | return [f for f, t in merged]
71 |
72 |
73 | def parse_verbal_timedelta_string(timedelta_string: str) -> datetime.timedelta:
74 | """Parse a timedelta string like "1 year, 2 days, 3 hours, 4 mn" into a timedelta object.
75 |
76 | The string does not have to contain all components. The only requirement is that the
77 | components are separated by ", "."""
78 |
79 | years = days = hours = minutes = 0
80 |
81 | # Parse each part
82 | for part in timedelta_string.split(", "):
83 | if "year" in part:
84 | years = int(part.split(" ")[0])
85 | elif "day" in part:
86 | days = int(part.split(" ")[0])
87 | elif "hour" in part:
88 | hours = int(part.split(" ")[0])
89 | elif ("mn" in part) or ("min" in part):
90 | minutes = int(part.split(" ")[0])
91 |
92 | # Convert years to days (approximate, assuming 365 days per year)
93 | days += years * 365
94 |
95 | # Create and return the timedelta object
96 | return datetime.timedelta(days=days, hours=hours, minutes=minutes)
97 |
--------------------------------------------------------------------------------
/packages/core/utils/tum_enclosure_logger.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 |
4 | import tum_esm_utils
5 |
6 | from packages.core import types
7 |
8 | _PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=4)
9 |
10 |
11 | class TUMEnclosureLogger:
12 | """A class to save the current TUM Enclosure state to a log file."""
13 |
14 | @staticmethod
15 | def log(config: types.Config, state: types.StateObject) -> None:
16 | now = datetime.datetime.now()
17 | timestamp = now.timestamp()
18 | datetime_string = now.isoformat()
19 |
20 | log_line = [
21 | timestamp,
22 | datetime_string,
23 | state.position.sun_elevation,
24 | state.tum_enclosure_state.power.heater,
25 | state.tum_enclosure_state.power.spectrometer,
26 | state.tum_enclosure_state.state.rain,
27 | state.tum_enclosure_state.actors.fan_speed,
28 | state.tum_enclosure_state.actors.current_angle,
29 | state.tum_enclosure_state.sensors.temperature,
30 | state.tum_enclosure_state.sensors.humidity,
31 | ]
32 | stringed_log_line = [str(item).lower() if item is not None else "null" for item in log_line]
33 | current_log_file = os.path.join(
34 | _PROJECT_DIR,
35 | "logs",
36 | "tum-enclosure",
37 | f"{config.general.station_id}-tum-enclosure-logs-{now.strftime('%Y-%m-%d')}.csv",
38 | )
39 | if not os.path.isfile(current_log_file):
40 | with open(current_log_file, "w") as f:
41 | f.write(
42 | "timestamp,datetime,sun_elevation,heater_power,"
43 | + "spectrometer_power,rain_detected,fan_speed,"
44 | + "cover_angle,temperature,humidity\n"
45 | )
46 | with open(current_log_file, "a") as f:
47 | f.write(",".join(stringed_log_line) + "\n")
48 |
--------------------------------------------------------------------------------
/packages/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | /node_modules
3 |
4 | # Production
5 | /build
6 |
7 | # Generated files
8 | .docusaurus
9 | .cache-loader
10 |
11 | # Misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/packages/docs/.node-version:
--------------------------------------------------------------------------------
1 | 20.13.1
--------------------------------------------------------------------------------
/packages/docs/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
3 | };
4 |
--------------------------------------------------------------------------------
/packages/docs/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/bun.lockb
--------------------------------------------------------------------------------
/packages/docs/docs/contributor-guide/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "Contributor Guide",
3 | "position": 4
4 | }
5 |
--------------------------------------------------------------------------------
/packages/docs/docs/contributor-guide/becoming-a-contributor.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 1
3 | sidebar_label: Becoming a Contributor
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # How to Become a Contributor
8 |
9 | 1. Please read the sections [**Repository Organization**](/docs/developer-guide/repository-organization) and [**Testing and CI**](/docs/developer-guide/testing-and-ci)
10 | 2. Look for your feature idea/bug fix on our [**issue tracker**](https://github.com/tum-esm/pyra/issues)
11 | 3. Submit an issue if your contribution idea is not being worked on yet [**using our issue templates**](https://github.com/tum-esm/pyra/issues/new/choose)
12 | 4. [**Reach out to us**](/docs/intro/contact) if you want to discuss your ideas or want to add your solutions
13 |
14 |
15 |
16 | Original by Nedroid
17 |
--------------------------------------------------------------------------------
/packages/docs/docs/developer-guide/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "Developer Guide",
3 | "position": 3
4 | }
5 |
--------------------------------------------------------------------------------
/packages/docs/docs/developer-guide/architecture.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 1
3 | hide_table_of_contents: true
4 | ---
5 |
6 | # Architecture
7 |
8 | **The hierarchy between the three parts of Pyra:**
9 |
10 |
14 |
15 | **Inside Pyra Core's main loop:**
16 |
17 | - The Core will infinitely loop over the modules `MeasurementConditions`, `EnclosureControl`, `SunTracking`, `OpusMeasurements`, and `SystemChecks`
18 | - The Core will start the Upload- and Helios-Thread when they should be running but are not
19 | - The threads will stop themselves based on the config
20 |
21 | **The communication via the `state.json` file:**
22 |
23 |
27 |
28 |
29 |
30 |
31 | ## Pyra Core Directory Structure
32 |
33 | ### Responsibilities
34 |
35 | - `types` contains all types used in the codebase. The whole codebase has static-type hints. A static-type analysis can be done using MyPy (see `scripts/`).
36 | - `utils` contains all supporting functionality used in one or more places.
37 | - `interfaces` includes the "low-level" code to interact with the PLC, the operating system, and the config- and state files.
38 | - `threads` contains the logic that Pyra Core runs parallel to the main thread.
39 |
40 | ### Import hierarchy
41 |
42 | ```mermaid
43 | graph LR;
44 | A["types"] -- imported by --> B;
45 | B["utils"] -- imported by --> C;
46 | C["interfaces"] -- imported by --> D;
47 | D["threads"] -- imported by --> E["main.py"];
48 | ```
49 |
50 | _\* the graph is transient_
51 |
--------------------------------------------------------------------------------
/packages/docs/docs/developer-guide/external-interfaces.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 3
3 | sidebar_label: External Interfaces
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Interfaces with external programs
8 |
9 | ## OPUS
10 |
11 | Up to version 4.1.4, PYRA used DDE to communicate with OPUS. Since Microsoft replaced it internally with Windows 95, the interface has only been kept for compatibility reasons. This means that there is almost no documentation or libraries for DDE. Since OPUS also has an HTTP interface, Pyra 4.2.0 and above uses this HTTP interface. We added the OPUS HTTP Client to our Python utility library, so one can use it outside of PYRA to: [see `tum-esm-utils`](https://tum-esm-utils.netlify.app/example-usage#opus-http-interface).
12 |
13 | ## CamTracker
14 |
15 | The `CamTrackerConfig.txt` file contains geographical coordinates Pyra Core uses in its Astronomy utility class. The coordinates can be found by looking for the `$1` mark inside the file:
16 |
17 | ```
18 | ...
19 |
20 | $1
21 | 48.15
22 | 11.57
23 | 0.54
24 |
25 | ...
26 | ```
27 |
28 | The `LEARN_Az_Elev.dat` file contains CamTracker's logs about the mirror position and the currently estimated sun position. Example:
29 |
30 | ```dat
31 | Julian Date, Tracker Elevation, Tracker Azimuth, Elev Offset from Astro, Az Offset from Astro, Ellipse distance/px
32 | 2458332.922778,107.490800,149.545000,0.197305,0.188938,705.047211
33 | 2458332.922836,107.494400,149.761400,0.192179,0.365420,736.914133
34 | 2458332.922905,107.498400,150.208200,0.188914,0.778934,736.914133
35 | 2458332.922975,107.499200,149.811600,0.179557,0.335728,736.914133
36 | 2458332.923032,107.508400,149.647800,0.182958,0.145281,736.914133
37 | ...
38 | ```
39 |
40 | The `SunIntensity.dat` file contains CamTracker's own evaluation of the sun conditions. This is currently not used in Pyra Core, but it might be in the future. Example:
41 |
42 | ```dat
43 | Julian Date, Date UTC[yyyy/MM/dd HH:mm:ss], Intensity[px*ms], big and small obj good
44 | 2457057.199028, 2015/02/03 16:46:36, 0.000000, good
45 | 2457057.200347, 2015/02/03 16:48:30, 0.000000, good
46 | 2458332.906019, 2018/08/02 09:44:40, 0.000000, good
47 | 2458332.906088, 2018/08/02 09:44:46, nan, bad
48 | 2458332.906169, 2018/08/02 09:44:53, nan, bad
49 | ...
50 | ```
51 |
52 | When stopping CamTracker, we have to add a `stop.txt` inside CamTracker's code directory, and the application will shut down gracefully.
53 |
54 | ## TUM PLC
55 |
56 | We are using the Snap7 Python library to communicate with the TUM PLC. Here is a manual for the underlying Snap7 API: http://snap7.sourceforge.net/sharp7.html
57 |
--------------------------------------------------------------------------------
/packages/docs/docs/developer-guide/internal-interfaces.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 2
3 | sidebar_label: Internal Interfaces
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Interfaces used internally
8 |
9 | ## `config.json`
10 |
11 | The config file under `config/config.json` contains all the parameters to tweak Pyra's operation. Schema: [/packages/core/types/config.py](https://github.com/tum-esm/pyra/blob/main/packages/core/types/config.py).
12 |
13 | ## `state.json`
14 |
15 | The state file is generated under `logs/state.json`. Pyra Core writes its internal values to this file. The state file is used to communicate between modules as well as with the "outside" world (UI, CLI). Schema: [/packages/core/types/state.py](https://github.com/tum-esm/pyra/blob/main/packages/core/types/state.py).
16 |
17 | ## Validation strategy
18 |
19 | [MyPy](https://github.com/python/mypy) will make full use of the schemas included above (see [testing](/docs/developer-guide/testing-and-ci)). Whenever loading the config- or state files, the respective schema validation will run. Hence, Pyra will detect when a JSON file does not have the expected schema and raise a precise Exception. All internal code interfaces (function calls, etc.) are covered by the strict MyPy validation.
20 |
21 | :::info
22 |
23 | With `pyra-cli config get`, only the schema will be validated, not the value rules. This command is necessary because the UI can deal with invalid values but not with an invalid schema.
24 |
25 | :::
26 |
27 | ## How the UI reads logs and state
28 |
29 | The UI reads the log files and the state file periodically using [Tauri's file system API](https://tauri.app/v1/api/js/modules/fs). We tested using sockets or file watchers, but both did not work well on Windows and reading it periodically is the most basic implementation.
30 |
31 | ## Logging
32 |
33 | All scripts that output messages at runtime should use the `Logger` class:
34 |
35 | ```python
36 | from packages.core import utils
37 |
38 | logger = utils.Logger()
39 |
40 | logger.debug("...")
41 | logger.info("...")
42 | logger.warning("...")
43 | logger.critical("...")
44 | logger.error("...")
45 |
46 |
47 | # By default, it will log from a "pyra.core" origin
48 | logger = utils.Logger()
49 |
50 | # Here, it will log from a "camtracker" origin
51 | logger = utils.Logger(origin="camtracker")
52 | ```
53 |
54 | Messages from all log levels can be found in `logs/debug.log`, and messages from levels INFO/WARNING/CRITICAL/ERROR can be found in `logs/info.log`.
55 |
56 | ## Activity Log
57 |
58 | _Pyra Core_ stores its activity (is it measuring, do errors exist, etc.) in `logs/activity/activity-YYYY-MM-DD.json`. This is the same information as in the regular log files but significantly easier to parse. Schema: [/packages/core/types/activity_history.py](https://github.com/tum-esm/pyra/blob/main/packages/core/types/activity_history.py).
59 |
60 | ## Pyra CLI commands from UI
61 |
62 | All write operations from the UI (update config, etc.) are done by running Pyra CLI commands. This is why we have to use the global Python interpreter instead of a virtual environment: We did not make it work that the [shell interface from Tauri](https://tauri.app/v1/api/js/modules/shell) can make use of a virtual environment.
63 |
--------------------------------------------------------------------------------
/packages/docs/docs/developer-guide/running-pyra-manually.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 4
3 | hide_table_of_contents: true
4 | ---
5 |
6 | # Running Pyra manually
7 |
8 | ## Install dependencies
9 |
10 | Dependency management uses [PDM](https://pdm-project.org/latest). `pdm install --dev` installs all dependencies in the currently activated interpreter environment.
11 |
12 | ## Pyra Core
13 |
14 | Normally, Pyra should be started via the CLI:
15 |
16 | ```bash
17 | pyra-cli core start
18 | pyra-cli core is-running
19 | pyra-cli core stop
20 | ```
21 |
22 | The start command will execute `python run-pyra-core.py` in a background process.
23 |
24 | ## Upload/Helios threads
25 |
26 | In Pyra Core's regular operation, it will start and stop Upload and Helios in dedicated threads. These threads will write their output into the Core's log files. You can run these two threads in headless mode (without Pyra Core), and they will print all of their logs to the console:
27 |
28 | ```bash
29 | python run-headless-helios-thread.py
30 | python run-headless-upload-thread.py
31 | ```
32 |
33 | ## Building the frontend
34 |
35 | Inside the `packages/ui` directory:
36 |
37 | - `yarn` will install all dependencies
38 | - `yarn tauri dev` will start a development server (with hot reload)
39 | - `yarn tauri build` will bundle the Tauri application and put the installer for your operating system into the `packages/ui/src-tauri/target/release/bundle` directory
40 |
41 | ## Building the documentation
42 |
43 | Inside the `packages/docs` directory:
44 |
45 | - `yarn` will install all dependencies
46 | - `yarn develop` will start a development server (with hot reload)
47 | - `yarn build` will render the static HTML and bundle the SPA into the `packages/docs/build` directory
48 |
--------------------------------------------------------------------------------
/packages/docs/docs/developer-guide/testing-and-ci.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 5
3 | sidebar_label: Testing & CI
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Testing and Continuous Integration
8 |
9 | ## Testing Strategy
10 |
11 | The complete software only runs when OPUS and CamTracker are installed and an EM27 is connected. However, in test mode, the software can run independently without interacting with any modules requiring a particular setup. Most functionality is tested by running the software and conducting measurements. Since Pyra only starts/stops the measurement process but does not affect output files, these output files from OPUS do not have to be tested.
12 |
13 | ## Testing with Pytest
14 |
15 | Testing is done using [PyTest](https://github.com/pytest-dev/pytest/). All tests can be found in the `tests/` directory. There are two types of test functions:
16 |
17 | ```python
18 | # can be run without config.json/hardware setup
19 | @pytest.mark.ci
20 | def test_something():
21 | pass
22 |
23 | # require config.json and hardware setup
24 | @pytest.mark.integration
25 | def test_something_else():
26 | pass
27 | ```
28 |
29 | Run the two test categories with:
30 |
31 | ```bash
32 | python -m pytest -m "ci" tests
33 | python -m pytest -m "integration" tests
34 | ```
35 |
36 | The following can be used to test whether **emailing and uploading** is configured correctly:
37 |
38 | ```bash
39 | python -m pytest tests/integration/test_emailing.py
40 | python -m pytest tests/integration/test_upload.py
41 | ```
42 |
43 | We are using the strict MyPy settings with a few exceptions that can be found in `pyproject.toml`.
44 |
45 | ## Static Typing for Pyra UI
46 |
47 | The whole frontend is written in [Typescript](https://github.com/microsoft/TypeScript). Testing the code's integrity can be done by building the UI:
48 |
49 | ```bash
50 | cd packages/ui
51 | yarn build
52 | ```
53 |
54 | ## Continuous Integration
55 |
56 | **Test on Main:** Will run all CI tests, MyPy, and build the UI _on every commit on `main` or every PR on `main`_.
57 |
58 | **Build on Prerelease:** Will build the UI and generate a release named "vX.Y.Z-prerelease (generated by CI)" that has the `.msi` file attached to it _on every commit on `prerelease`_.
59 |
60 | **Build on Release:** Will build the UI and generate a release named "vX.Y.Z (generated by CI)" that has the `.msi` file attached to it _on every commit on `release`_.
61 |
62 | :::info
63 |
64 | The `release` branch should only contain code from the latest official release. Use the `prerelease` branch to bundle the installable UI on demand.
65 |
66 | :::
67 |
--------------------------------------------------------------------------------
/packages/docs/docs/intro/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "Getting Started",
3 | "position": 1
4 | }
5 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "User Guide",
3 | "position": 2
4 | }
5 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/automatic-peak-positioning.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 6
3 | sidebar_label: Automatic Peak Positioning
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Automatic Peak Positioning (APP)
8 |
9 | Whenever powering up the spectrometer, the EM27/SUN does not accurately know the pendulum's position. That is why it is recommended to save the center position of recorded interferograms after every powerup so that no points on one of the sides of the interferogram get lost because it is not stored centered. In OPUS, you can do this in "Measure > Setup Measurement Parameters > Check Signal". Or you can let PYRA do this after every powerup by configuring it in the OPUS Configuration and save a few minutes per system per day.
10 |
11 | The Automatic Peak Positioning (APP) feature searches for OPUS files written to the local disk within the last 15 minutes and after the last EM27/SUN powerup. It loads the interferograms from these OPUS files using the [`tum-esm-utils`](https://tum-esm-utils.netlify.app/api-reference#tum_esm_utilsopus) Python library and calculates the peak position. If the peak position from the last five readable OPUS files is identical and is less than 200 points off the center, this new peak position will be sent to the EM27/SUN.
12 |
13 | The logs from the OPUS thread when performing the APP will look something like this:
14 |
15 | ```log
16 | 2025-04-19 04:44:23.020353 UTC+0000 - opus - DEBUG - Starting iteration
17 | 2025-04-19 04:44:23.023355 UTC+0000 - opus - DEBUG - Loading configuration file
18 | 2025-04-19 04:44:23.042385 UTC+0000 - opus - DEBUG - Checking if OPUS is running
19 | 2025-04-19 04:44:23.082358 UTC+0000 - opus - DEBUG - Macro is running as expected
20 | 2025-04-19 04:44:23.116354 UTC+0000 - opus - INFO - Trying to set peak position
21 | 2025-04-19 04:44:23.140355 UTC+0000 - opus - DEBUG - APP: Time since last powerup is 5280.00 seconds
22 | 2025-04-19 04:44:23.148357 UTC+0000 - opus - DEBUG - APP: Found 9 files created since the last powerup and less than 10 minutes old
23 | 2025-04-19 04:44:23.152385 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0009 - Could not determine peak position ([Errno 13] Permission denied: 'C:\MESSUNGEN\20250419\ma20250419s0e00a.0009')
24 | 2025-04-19 04:44:23.204388 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0008 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.03186991322785616)
25 | 2025-04-19 04:44:23.258355 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0007 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.0316972940415144)
26 | 2025-04-19 04:44:23.316386 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0006 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.03151779551059008)
27 | 2025-04-19 04:44:23.371357 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0005 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.031326398849487305)
28 | 2025-04-19 04:44:23.427355 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0004 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.031152355410158636)
29 | 2025-04-19 04:44:23.430355 UTC+0000 - opus - DEBUG - APP: Currently configured ABP is 60237
30 | 2025-04-19 04:44:23.434366 UTC+0000 - opus - DEBUG - Currently recorded interferograms have peak positions: 57123 (-5 points offset from center)
31 | 2025-04-19 04:44:23.437387 UTC+0000 - opus - INFO - Updating peak position from 60237 to 60232
32 | 2025-04-19 04:44:23.512354 UTC+0000 - opus - DEBUG - Sleeping 19.51 seconds
33 | ```
34 |
35 | Ensure to unload the interferogram files frequently because the files loaded by OPUS cannot be read by any other program. Otherwise, you will see many `Permission denied` exceptions until Pyra finds the first interferogram file from which to read the peak position.
36 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/faq.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 9
3 | sidebar_label: FAQ
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # FAQ
8 |
9 | ## Can we use Pyra on an offline system?
10 |
11 | Yes. Pyra only requires an internet connection for the installation. Pyra releases from 4.0.8 download astronomical data (until 2053) during installation, so no further internet connection during operation is required.
12 |
13 | ## During Setup, the following error occurs: "executing `.ps1` scripts is not allowed due to an ExecutionPolicy"
14 |
15 | This can be solved by running the following command in a PowerShell (credits to https://stackoverflow.com/a/49112322/8255842):
16 |
17 | ```powershell
18 | Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Unrestricted
19 | ```
20 |
21 | ## Pyra Shows a `filelock._error.Timeout` Exception
22 |
23 | In theory, Pyra may encounter a deadlock when writing and reading state or log files. It is very rare (< once per instrument per 5 years) and mostly self-resolving since Pyra 4.2.1. The error message will say something like this:
24 |
25 | ```
26 | filelock._error.Timeout: The file lock 'C:\Users\ga56fem\Downloads\pyra\pyra-4.0.6\config\.state.lock' could not be acquired.
27 | ```
28 |
29 | Stopping and restarting Pyra core resolves this error.
30 |
31 | :::note
32 |
33 | We are using the Python library `filelock` to enforce exclusive use of state- and log-files: There should not be two processes simultaneously interacting with one of these files. This [semaphore]() is necessary because Pyra runs many threads accessing these files in parallel.
34 |
35 | :::
36 |
37 | ## Pyra Shuts Down The CamTracker Very Frequently
38 |
39 | The config setting `config.camtracker.motor_offset_threshold`, set to `10` degrees by default, controls the shutdown of CamTracker if it drifts too much.
40 |
41 | CamTracker reports its motor's offset from the theoretical sun position every few seconds. When CamTracker has been running for at least 5 minutes, Pyra will check whether that reported offset is greater than the threshold. If so, Pyra will restart the CamTracker because this might mean that the CamTracker has lost the sun and is tracking something else in the sky.
42 |
43 | In normal operation, you would see (debug) log lines like this every iteration:
44 |
45 | ```log
46 | DEBUG - CamTracker motor position is valid
47 | ```
48 |
49 | If the motor position is not valid, you will see something like this:
50 |
51 | ```log
52 | INFO - CamTracker motor position is over threshold.
53 | INFO - Stopping CamTracker. Preparing for reinitialization.
54 | ```
55 |
56 | If that restart happens too often for your use case, you can increase this factor - or set it to `360` to disable it completely.
57 |
58 | ## Exception "OPUS HTTP interface did not start within 90 seconds"
59 |
60 | When setting up a new system, it can happen that Pyra cannot connect to OPUS via the HTTP interface.
61 |
62 | 1. The first time you start Pyra core, you will be asked to grant it some firewall permissions – in a Windows popup.
63 |
64 | 2. After that, it is possible that some other software is using the localhost with a webserver. You have to turn off this other software or move it to another port. Common software being enabled by default: "Microsoft Internet Information Service" and "XAMPP".
65 |
66 | ## The system storage is very full even though there is not much data on it
67 |
68 | Due to the 24/7 operation of the systems, Windows might collect large amounts of temporary files and not remove them on restarts. We cannot influence this behavior but only raise an exception when the system's storage is filled up by more than 90%.
69 |
70 | You can use the app [TreeSize](https://apps.microsoft.com/detail/xpddxv3sd1sb5k) or its free version [TreeSize Free](https://apps.microsoft.com/detail/xp9m26rsclnt88) to check where the storage is used.
71 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/functionality.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 1
3 | hide_table_of_contents: true
4 | ---
5 |
6 | # Functionality
7 |
8 | Measurements with an EM27/SUN require you to run CamTracker - the software that tracks the sun position and operates the mirrors accordingly - and OPUS - the software that sends commands to the EM27/SUN and collects the output data into interferogram files. Fully automated setups usually have a weather protection enclosure and possibly custom hardware to evaluate measurement conditions.
9 |
10 |
11 |
12 | Pyra bridges the gap between the components required to automate your EM27/SUN setup. Based on certain conditions - time, sun angle, or sun condition (see [User Guide > TUM PLC and Helios](/docs/user-guide/tum-enclosure-and-helios)) - Pyra decides whether to perform measurements or not. This decision can also be outsourced to your own logic and passed to Pyra via CLI commands or done manually with a button in the UI (see [User Guide > Measurement Modes](/docs/user-guide/measurement-modes)).
13 |
14 |
15 |
16 | Pyra monitors the measurement process and operating system stability (disk space, CPU usage, etc.), permanently logs its activity, and sends emails when errors occur/have been resolved.
17 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/measurement-modes.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 4
3 | sidebar_label: Measurement Modes
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Measurement Modes
8 |
9 | The **"measurement decision"** (whether measurements should run or not) depends on the **"measurement mode"**:
10 |
11 | - **Automatic mode:** Measurement decision is made based on defined triggers (see below)
12 | - **Manual mode:** A start/stop button for manual control
13 | - **CLI mode:** The config contains a field where the CLI can add a decision result
14 |
15 | Pyra's measurement mode can be selected in the overview tab. In the logs and the codebase, this system of determining whether to measure or not is called **Condition Assessment System (CAS)**.
16 |
17 |
18 |
19 | ## Automatic Mode
20 |
21 | In automatic mode, Pyra does measurements when certain conditions are met:
22 |
23 | - start/stop time
24 | - minimum sun elevation
25 | - Helios
26 |
27 | In the configuration tab, you can select measurement triggers that should be considered in automatic mode. When multiple triggers are set, all triggers must be positive to start measurements (e.g., "above a certain sun elevation AND between start and end time").
28 |
29 |
30 |
31 | **Helios** is our module to determine whether direct sunlight (required by the EM27/SUN) is present. Please read about it [here](/docs/user-guide/tum-enclosure-and-helios).
32 |
33 | ## Manual Mode
34 |
35 | In manual mode, the system will always measure when the sun is about the value configured at `config.general.min_sun_elevation` (you can set this to negative values too).
36 |
37 | ## CLI Mode
38 |
39 | CLI mode can be used when you have already built a system that evaluates measurement conditions and want to tell Pyra when to start and stop measurements from an external source. Respective CLI commands are in the [next section](#cli-command-line-interface).
40 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/tum-enclosure-and-helios.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 8
3 | hide_table_of_contents: true
4 | ---
5 |
6 | # TUM Enclosure & Helios
7 |
8 | The TUM weather protection enclosure and Helios setup hardware are not available to buy off the shelf yet. Please [contact us](/docs/intro/contact) if you want to acquire our hardware.
9 |
10 |
11 |
12 | The TUM hardware used by [MUCCnet](https://atmosphere.ei.tum.de/) has been described in [Dietrich et al. 2021 (doi.org/10.5194/amt-14-1111-2021)](https://doi.org/10.5194/amt-14-1111-2021) and [Heinle et al. 2018 (doi.org/10.5194/amt-11-2173-2018)](https://doi.org/10.5194/amt-11-2173-2018).
13 |
14 | :::tip
15 |
16 | Pyra does not have power over the PLC concerning safety-related functions. When the PLC is in the "rain was detected" state, the cover will move to its parking position at 0°. Pyra (or any other process on the PC) cannot change that state or move the cover out of the parking position.
17 |
18 | :::
19 |
20 | ## What does Helios do?
21 |
22 | Helios evaluates the current sun state - whether direct or diffuse sunlight exists. EM27/SUN applications require direct sunlight for measurement data to be useful. Additionally, CamTracker will lose track of the sun with very diffuse light conditions and requires a restart.
23 |
24 | The Helios hardware comprises a transparent globe with black stripes glued to it and a translucent milky glass with a camera pointing upwards attached below.
25 |
26 |
27 |
31 |
32 |
33 | Helios will periodically take images with that camera, process them, and evaluate shadow conditions. The processing will detect the lens circle and cut off the outer 10% of the radius - a lot of unwanted shadows can occur on the edges due to dirt from the outside. Finally, it will use a canny edge filter and count the pixels where "fast transitions between light and dark" can be found. If the pixel count is above a certain threshold (configurable), the sunlight conditions are categorized as "good".
34 |
35 | Example images in **bad** conditions:
36 |
37 |
38 |
42 |
46 |
47 |
48 | Example images in **good** conditions:
49 |
50 |
51 |
55 |
59 |
60 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/upload.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 7
3 | sidebar_label: Upload
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Upload
8 |
9 | You can use this upload to transfer your interferograms, local pressure files, Pyra logs, helios images, or any other data generated day by day.
10 |
11 | You can upload daily directories like this:
12 |
13 | ```
14 | 📁 data-directory-1
15 | ├── 📁 20190101
16 | │ ├── 📄 file1.txt
17 | │ ├── 📄 file2.txt
18 | │ └── 📄 file3.txt
19 | └── 📁 20190102
20 | ├── 📄 file1.txt
21 | ├── 📄 file2.txt
22 | └── 📄 file3.txt
23 | ```
24 |
25 | Or daily files like this:
26 |
27 | ```
28 | 📁 data-directory-2
29 | ├── 📄 20190101.txt
30 | ├── 📄 20190102-a.txt
31 | ├── 📄 20190102-b.txt
32 | └── 📄 20190103.txt
33 | ```
34 |
35 | You just have to point the upload to one or more directories and specify a "date regex" - i.e., a regex that contains the symbols `%Y`, `%m`, and `%d` that matches your file/directory naming scheme.
36 |
37 | As long as the upload runs, there will be a file `data-directory-1/20190101/.do-not-touch` or `data-directory-2/.do-not-touch` that indicates that the upload is not finished yet. This file will be deleted after the checksum of the local and remote directory matches.
38 |
39 | Optionally, you can remove the local data, once the upload is done.
40 |
41 | You can read more about the `circadian-scp-upload` library [here](https://github.com/dostuffthatmatters/circadian-scp-upload). We have already uploaded about 8TB of data using this upload code and use it since 2022. The library has CI tests for all kinds of file system layouts - feel free to use it for your own projects :)
42 |
--------------------------------------------------------------------------------
/packages/docs/docs/user-guide/usage-overview.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 3
3 | sidebar_label: Usage Overview
4 | hide_table_of_contents: true
5 | ---
6 |
7 | # Usage Overview
8 |
9 | :::tip
10 |
11 | Starting and stopping measurements and controlling PYRA from another system/automation will be covered in the next section [Measurement Modes](/docs/user-guide/measurement-modes).
12 |
13 | :::
14 |
15 | ## Parts of Pyra
16 |
17 | The three parts of Pyra are:
18 |
19 | - **Pyra Core:** Running in the background, looping over all automation steps infinitely in a defined interval.
20 | - **Pyra CLI:** Command Line Interface (CLI) used to interact with Pyra Core
21 | - **Pyra UI:** Graphical user Interface (GUI) used to interact with Pyra Core and send Pyra CLI commands
22 |
23 | ## Pyra Core
24 |
25 | Please read the [Developer Guide](/docs/developer-guide/architecture) for an overview of how the core is implemented.
26 |
27 | ## GUI (Graphical User Interface)
28 |
29 | This is how the GUI looks. The tab "PLC Controls" only appears when you have configured the [TUM PLC](/docs/user-guide/tum-enclosure-and-helios).
30 |
31 |
32 |
33 | When changes are made to the `config.json` file directly (not from the GUI), a popup will appear, telling you to reload the window:
34 |
35 |
36 |
37 | :::note
38 |
39 | We are currently experiencing a bug on some systems that the "config has changed, reload required" popup shows up when changes were made through the GUI. Also, the popup appears multiple times. This should be resolved in the future.
40 |
41 | The issue progress can be tracked here: https://github.com/tum-esm/pyra/issues/106
42 |
43 | :::
44 |
45 | ## Logs
46 |
47 | Pyra will store all of its activity in log files. The files `info.log` and `debug.log` will only contain logs from the past hour. Older log lines will be archived periodically to keep the main log files easy to parse.
48 |
49 | ```
50 | 📁
51 | 📁 pyra
52 | 📁 pyra-x.y.z
53 | 📁 logs
54 | 📄 info.log
55 | 📄 debug.log
56 | 📁 archive
57 | 📄 YYYYMMDD-info.log
58 | 📄 YYYYMMDD-debug.log
59 | 📄 ...
60 | ```
61 |
62 | Inside the Logs tab, the content from the log files will be fetched every 10 seconds. The debug level includes more information. The log fetching can be paused (on the "live" button) for analyzing logs in detail. There is a button that opens the `pyra/pyra-x.y.z/logs` folder in the file explorer.
63 |
64 |
65 |
66 | ## Error Emails
67 |
68 | Whenever Pyra encounters any errors, it sends out emails to a list of recipients. An "error resolved" email helps with self-resolving issues and team communication - so that others get notified when the problem is not present anymore.
69 |
70 | **When errors occur:**
71 |
72 |
76 |
77 | **When errors are resolved:**
78 |
79 |
83 |
84 | :::note
85 |
86 | The "CoverError" comes from the TUM PLC Module controlling our weather protection enclosure.
87 |
88 | :::
89 |
--------------------------------------------------------------------------------
/packages/docs/docusaurus.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | // Note: type annotations allow type checking and IDEs autocompletion
3 |
4 | const lightCodeTheme = require("prism-react-renderer/themes/github");
5 | const darkCodeTheme = require("prism-react-renderer/themes/dracula");
6 |
7 | async function createConfig() {
8 | const mdxMermaid = await import("mdx-mermaid");
9 |
10 | /** @type {import('@docusaurus/types').Config} */
11 | return {
12 | title: "PYRA 4",
13 | tagline: "operate an EM27 station autonomously",
14 | staticDirectories: ["static"],
15 | url: "https://your-docusaurus-test-site.com",
16 | baseUrl: "/",
17 | onBrokenLinks: "throw",
18 | onBrokenMarkdownLinks: "throw",
19 | favicon: "img/favicon.ico",
20 |
21 | // GitHub pages deployment config.
22 | organizationName: "tum-esm",
23 | projectName: "pyra",
24 |
25 | // Even if you don't use internalization, you can use this field to set useful
26 | // metadata like html lang.
27 | i18n: {
28 | defaultLocale: "en",
29 | locales: ["en"],
30 | },
31 |
32 | plugins: ["docusaurus-plugin-sass"],
33 |
34 | presets: [
35 | [
36 | "classic",
37 | /** @type {import('@docusaurus/preset-classic').Options} */
38 | ({
39 | docs: {
40 | sidebarPath: require.resolve("./sidebars.js"),
41 | // Remove this to remove the "edit this page" links.
42 | editUrl: "https://github.com/tum-esm/pyra/tree/main/website/",
43 | remarkPlugins: [mdxMermaid.default],
44 | },
45 | theme: {
46 | customCss: [
47 | require.resolve("./src/css/fonts.css"),
48 | require.resolve("./src/css/custom.scss"),
49 | ],
50 | },
51 | }),
52 | ],
53 | ],
54 |
55 | themeConfig:
56 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
57 | ({
58 | colorMode: {
59 | defaultMode: "light",
60 | disableSwitch: true,
61 | respectPrefersColorScheme: false,
62 | },
63 | navbar: {
64 | title: "PYRA 4",
65 | logo: {
66 | alt: "PYRA 4 Logo",
67 | src: "img/logo.svg",
68 | width: 32,
69 | height: 32,
70 | },
71 | items: [
72 | {
73 | href: "https://github.com/tum-esm/pyra",
74 | position: "right",
75 | className: "header-github-link",
76 | },
77 | ],
78 | hideOnScroll: true,
79 | },
80 | footer: {
81 | style: "dark",
82 | copyright: `© ${new Date().getFullYear()} - Associate Professorship of Environmental Sensing and Modeling - Technical University of Munich`,
83 | },
84 | prism: {
85 | theme: require("prism-react-renderer/themes/github"),
86 | additionalLanguages: ["python"],
87 | },
88 | algolia: {
89 | appId: "HVYNW5V940",
90 | apiKey: "6f156c973f748938883016d17df0fbf7", // public API key
91 | indexName: "pyra-4",
92 | searchPagePath: false,
93 | },
94 | }),
95 | };
96 | }
97 |
98 | module.exports = createConfig;
99 |
--------------------------------------------------------------------------------
/packages/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "docusaurus": "docusaurus",
7 | "dev": "docusaurus start",
8 | "build": "docusaurus build",
9 | "swizzle": "docusaurus swizzle",
10 | "deploy": "docusaurus deploy",
11 | "clear": "docusaurus clear",
12 | "serve": "docusaurus serve",
13 | "write-translations": "docusaurus write-translations",
14 | "write-heading-ids": "docusaurus write-heading-ids",
15 | "typecheck": "tsc"
16 | },
17 | "dependencies": {
18 | "@docusaurus/core": "^2.4.0",
19 | "@docusaurus/preset-classic": "^2.4.0",
20 | "@mdx-js/react": "^1.6.22",
21 | "@reduxjs/toolkit": "^1.9.3",
22 | "@tabler/icons-react": "^3.31.0",
23 | "@tauri-apps/api": "^1.2.0",
24 | "clsx": "^1.2.1",
25 | "deep-diff": "^1.0.2",
26 | "docusaurus-plugin-sass": "^0.2.3",
27 | "lodash": "^4.17.21",
28 | "mdx-mermaid": "^1.3.2",
29 | "mermaid": "^9.1.6",
30 | "moment": "^2.29.4",
31 | "prism-react-renderer": "^1.3.5",
32 | "react": "^18.2.0",
33 | "react-dom": "^18.2.0",
34 | "react-hot-toast": "^2.4.0",
35 | "react-redux": "^8.0.5",
36 | "sass": "^1.59.3",
37 | "socket.io-client": "^4.6.1",
38 | "typedoc": "^0.23.28",
39 | "typedoc-plugin-markdown": "^3.14.0"
40 | },
41 | "devDependencies": {
42 | "@docusaurus/module-type-aliases": "^2.4.0",
43 | "@tsconfig/docusaurus": "^1.0.5",
44 | "autoprefixer": "^10.4.8",
45 | "docusaurus-plugin-typedoc": "^0.18.0",
46 | "postcss": "^8.4.16",
47 | "tailwindcss": "^3.1.8",
48 | "typescript": "^4.7.4"
49 | },
50 | "browserslist": {
51 | "production": [
52 | ">0.5%",
53 | "not dead",
54 | "not op_mini all"
55 | ],
56 | "development": [
57 | "last 1 chrome version",
58 | "last 1 firefox version",
59 | "last 1 safari version"
60 | ]
61 | },
62 | "engines": {
63 | "node": ">=20.13"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/packages/docs/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/packages/docs/sidebars.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
4 | const sidebars = {
5 | tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }],
6 | };
7 |
8 | module.exports = sidebars;
9 |
--------------------------------------------------------------------------------
/packages/docs/src/components/github-label.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function GitHubLabel(props: { text: string }): JSX.Element {
4 | return (
5 |
6 | {props.text}
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/packages/docs/src/components/track-on-github.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import GitHubLabel from '@site/src/components/github-label';
4 |
5 | export default function TrackOnGitHub(props: {
6 | url: string;
7 | text: string;
8 | releaseDate?: string;
9 | }): JSX.Element {
10 | return (
11 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/packages/docs/src/css/components/navbar.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/src/css/components/navbar.scss
--------------------------------------------------------------------------------
/packages/docs/src/pages/index.module.css:
--------------------------------------------------------------------------------
1 | /**
2 | * CSS files with the .module.css suffix will be treated as CSS modules
3 | * and scoped locally.
4 | */
5 |
6 | .heroBanner {
7 | padding: 4rem 0;
8 | text-align: center;
9 | position: relative;
10 | overflow: hidden;
11 | }
12 |
13 | @media screen and (max-width: 996px) {
14 | .heroBanner {
15 | padding: 2rem;
16 | }
17 | }
18 |
19 | .buttons {
20 | display: flex;
21 | align-items: center;
22 | justify-content: center;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/docs/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Redirect } from 'react-router-dom';
3 |
4 | export default function Home() {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/docs/static/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/.nojekyll
--------------------------------------------------------------------------------
/packages/docs/static/drawings/exports/package-architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/drawings/exports/package-architecture.png
--------------------------------------------------------------------------------
/packages/docs/static/drawings/exports/setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/drawings/exports/setup.png
--------------------------------------------------------------------------------
/packages/docs/static/drawings/exports/state-architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/drawings/exports/state-architecture.png
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff2
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff
--------------------------------------------------------------------------------
/packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff2
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/cli-config-update-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/cli-config-update-example.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/config-file-reload.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/config-file-reload.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/email-config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/email-config.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/email-error-occured.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/email-error-occured.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/email-errors-resolved.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/email-errors-resolved.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/environment-path-manual-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-1.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/environment-path-manual-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-2.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/environment-path-manual-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-3.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/environment-path-manual-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-4.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/helios-example-image-bad-processed.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-bad-processed.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/helios-example-image-bad-raw.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-bad-raw.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/helios-example-image-good-processed.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-good-processed.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/helios-example-image-good-raw.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-good-raw.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/helios-hardware.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-hardware.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/jia-profile-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/jia-profile-image.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/logs-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/logs-tab.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/measurement-modes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/measurement-modes.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/moritz-profile-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/moritz-profile-image.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/muccnet-image-roof.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/muccnet-image-roof.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/overview-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/overview-tab.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/patrick-profile-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/patrick-profile-image.jpg
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/pyra-cli-path-request.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/pyra-cli-path-request.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/python-310-path-automatic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/python-310-path-automatic.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/triggers-config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/triggers-config.png
--------------------------------------------------------------------------------
/packages/docs/static/img/docs/who-made-this-meme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/who-made-this-meme.png
--------------------------------------------------------------------------------
/packages/docs/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/favicon.ico
--------------------------------------------------------------------------------
/packages/docs/static/img/icons/icon-cheveron-up.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/docs/static/img/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/packages/docs/static/logo/pyra-4-icon-1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/logo/pyra-4-icon-1024.png
--------------------------------------------------------------------------------
/packages/docs/static/logo/pyra-4-icon-256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/logo/pyra-4-icon-256.png
--------------------------------------------------------------------------------
/packages/docs/static/logo/pyra-4-icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/logo/pyra-4-icon-512.png
--------------------------------------------------------------------------------
/packages/docs/static/logo/pyra-4-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/packages/docs/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // prettier-ignore
2 |
3 | const colors = require('tailwindcss/colors')
4 | const customColors = {
5 | gray: {
6 | 50: '#f8fafc',
7 | 75: '#f4f7fa',
8 | 100: '#f1f5f9',
9 | 150: '#e9eef4',
10 | 200: '#e2e8f0',
11 | 300: '#cbd5e1',
12 | 350: '#afbccc',
13 | 400: '#94a3b8',
14 | 500: '#64748b',
15 | 600: '#475569',
16 | 700: '#334155',
17 | 750: '#283548',
18 | 800: '#1e293b',
19 | 850: '#162032',
20 | 900: '#0f172a',
21 | 950: '#0B111F',
22 | },
23 |
24 | red: colors.red,
25 | green: colors.green,
26 | yellow: colors.yellow,
27 |
28 | blue: {
29 | 50: '#DCEEFB',
30 | 75: '#c9e7fc',
31 | 100: '#B6E0FE',
32 | 150: '#9DD2F9',
33 | 200: '#84C5F4',
34 | 300: '#62B0E8',
35 | 400: '#4098D7',
36 | 500: '#2680C2',
37 | 600: '#186FAF',
38 | 700: '#0F609B',
39 | 800: '#0A558C',
40 | 900: '#003E6B',
41 | },
42 |
43 | teal: {
44 | 50: '#f0fdfa',
45 | 75: '#defcf5',
46 | 100: '#ccfbf1',
47 | 200: '#99f6e4',
48 | 300: '#5eead4',
49 | 400: '#2dd4bf',
50 | 500: '#14b8a6',
51 | 600: '#0d9488',
52 | 700: '#0f766e',
53 | 800: '#115e59',
54 | 900: '#134e4a',
55 | 950: '#092725',
56 | },
57 | };
58 |
59 | module.exports = {
60 | content: ['./src/**/*.{js,jsx,ts,tsx,css,scss}', './docs/**/*.{md,mdx}'],
61 | theme: {
62 | extend: {
63 | colors: customColors,
64 | fill: customColors,
65 | },
66 | },
67 | plugins: [],
68 | prefix: 'tw-',
69 | };
70 |
--------------------------------------------------------------------------------
/packages/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // This file is not used in compilation. It is here just for a nice editor experience.
3 | "extends": "@tsconfig/docusaurus/tsconfig.json",
4 | "compilerOptions": {
5 | "baseUrl": "."
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/ui/.env.example:
--------------------------------------------------------------------------------
1 | VITE_PYTHON_INTERPRETER="system|venv"
2 | VITE_PYRA_DIRECTORY="~/Documents/pyra/pyra-4.2.2"
3 |
--------------------------------------------------------------------------------
/packages/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 | !src/lib
10 |
11 | # Editor directories and files
12 | .vscode/*
13 | !.vscode/extensions.json
14 | .idea
15 | .DS_Store
16 | *.suo
17 | *.ntvs*
18 | *.njsproj
19 | *.sln
20 | *.sw?
21 |
22 | # other
23 | node_modules
24 | dist
25 | dist-ssr
26 | *.local
--------------------------------------------------------------------------------
/packages/ui/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/bun.lockb
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/styles/index.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "src/components",
14 | "utils": "src/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Pyra UI
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pyra-ui",
3 | "version": "4.2.2",
4 | "private": true,
5 | "scripts": {
6 | "dev": "vite --port 3000",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview",
9 | "tauri": "tauri"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-checkbox": "^1.1.3",
13 | "@radix-ui/react-icons": "^1.3.2",
14 | "@radix-ui/react-select": "^2.1.4",
15 | "@radix-ui/react-separator": "^1.1.1",
16 | "@radix-ui/react-slot": "^1.1.1",
17 | "@reduxjs/toolkit": "^2.5.0",
18 | "@tabler/icons-react": "^3.26.0",
19 | "@tauri-apps/api": "^2.1.1",
20 | "@tauri-apps/plugin-cli": "^2.2.0",
21 | "@tauri-apps/plugin-dialog": "^2.2.0",
22 | "@tauri-apps/plugin-fs": "^2.2.0",
23 | "@tauri-apps/plugin-shell": "^2.2.0",
24 | "class-variance-authority": "^0.7.1",
25 | "clsx": "^2.1.1",
26 | "deep-diff": "^1.0.2",
27 | "lodash": "4.17.21",
28 | "moment": "^2.30.1",
29 | "react": "19.0.0",
30 | "react-dom": "19.0.0",
31 | "react-hot-toast": "^2.4.1",
32 | "recursive-diff": "^1.0.9",
33 | "tailwind-merge": "^2.5.5",
34 | "tailwindcss-animate": "^1.0.7",
35 | "zod": "^3.24.1",
36 | "zustand": "^5.0.2"
37 | },
38 | "devDependencies": {
39 | "@tailwindcss/forms": "0.5.9",
40 | "@tauri-apps/cli": "^2.1.0",
41 | "@types/deep-diff": "^1.0.5",
42 | "@types/lodash": "4.17.13",
43 | "@types/node": "^22.10.2",
44 | "@types/react": "19.0.2",
45 | "@types/react-dom": "19.0.2",
46 | "@vitejs/plugin-react": "4.3.4",
47 | "autoprefixer": "10.4.20",
48 | "postcss": "8.4.49",
49 | "tailwindcss": "3.4.17",
50 | "tilg": "0.1.1",
51 | "typedoc": "^0.27.5",
52 | "typescript": "5.7.2",
53 | "vite": "^6.0.4"
54 | },
55 | "prettier": {
56 | "trailingComma": "es5",
57 | "tabWidth": 4,
58 | "semi": true,
59 | "singleQuote": true,
60 | "printWidth": 100
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/ui/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/packages/ui/src-tauri/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 | WixTools
5 |
--------------------------------------------------------------------------------
/packages/ui/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "pyra-ui"
3 | version = "4.2.2"
4 | description = "Pyra UI"
5 | authors = ["Moritz Makowski "]
6 | license = ""
7 | repository = "https://github.com/tum-esm/pyra"
8 | default-run = "pyra-ui"
9 | edition = "2021"
10 | rust-version = "1.57"
11 |
12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
13 |
14 | [build-dependencies]
15 | tauri-build = { version = "2", features = [] }
16 |
17 | [dependencies]
18 | serde_json = "1.0"
19 | serde = { version = "1.0", features = ["derive"] }
20 | tauri = { version = "2", features = ["protocol-asset", "devtools"] }
21 | tauri-plugin-shell = "2"
22 | tauri-plugin-fs = "2"
23 | tauri-plugin-dialog = "2"
24 |
25 | [features]
26 | # by default Tauri runs in production mode
27 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
28 | default = ["custom-protocol"]
29 | # this feature is used used for production builds where `devPath` points to the filesystem
30 | # DO NOT remove this
31 | custom-protocol = ["tauri/custom-protocol"]
32 |
--------------------------------------------------------------------------------
/packages/ui/src-tauri/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/packages/ui/src-tauri/capabilities/migrated.json:
--------------------------------------------------------------------------------
1 | {
2 | "identifier": "migrated",
3 | "description": "permissions that were migrated from v1",
4 | "local": true,
5 | "windows": [
6 | "main"
7 | ],
8 | "permissions": [
9 | "core:default",
10 | "fs:allow-read-file",
11 | {
12 | "identifier": "fs:scope",
13 | "allow": [
14 | "$DOWNLOAD/pyra/**",
15 | "$DOCUMENT/pyra/**",
16 | "$DOCUMENT/work/esm/pyra/**"
17 | ]
18 | },
19 | {
20 | "identifier": "shell:allow-execute",
21 | "allow": [
22 | {
23 | "args": true,
24 | "cmd": "python",
25 | "name": "system-python",
26 | "sidecar": false
27 | },
28 | {
29 | "args": true,
30 | "cmd": ".venv/bin/python",
31 | "name": "venv-python",
32 | "sidecar": false
33 | }
34 | ]
35 | },
36 | "shell:allow-open",
37 | "dialog:allow-open",
38 | "dialog:allow-message",
39 | "dialog:allow-ask",
40 | "dialog:allow-confirm",
41 | "shell:default",
42 | "fs:default",
43 | "dialog:default"
44 | ]
45 | }
--------------------------------------------------------------------------------
/packages/ui/src-tauri/gen/schemas/capabilities.json:
--------------------------------------------------------------------------------
1 | {"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","fs:allow-read-file",{"identifier":"fs:scope","allow":["$DOWNLOAD/pyra/**","$DOCUMENT/pyra/**","$DOCUMENT/work/esm/pyra/**"]},{"identifier":"shell:allow-execute","allow":[{"args":true,"cmd":"python","name":"system-python","sidecar":false},{"args":true,"cmd":".venv/bin/python","name":"venv-python","sidecar":false}]},"shell:allow-open","dialog:allow-open","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","shell:default","fs:default","dialog:default"]}}
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/128x128.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/128x128@2x.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/32x32.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square107x107Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square107x107Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square142x142Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square142x142Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square150x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square150x150Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square284x284Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square284x284Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square30x30Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square30x30Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square310x310Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square310x310Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square44x44Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square44x44Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square71x71Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square71x71Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/Square89x89Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square89x89Logo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/StoreLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/StoreLogo.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/icon.icns
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/icon.ico
--------------------------------------------------------------------------------
/packages/ui/src-tauri/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/icon.png
--------------------------------------------------------------------------------
/packages/ui/src-tauri/src/main.rs:
--------------------------------------------------------------------------------
1 | #![cfg_attr(
2 | all(not(debug_assertions), target_os = "windows"),
3 | windows_subsystem = "windows"
4 | )]
5 |
6 | fn main() {
7 | tauri::Builder::default()
8 | .plugin(tauri_plugin_dialog::init())
9 | .plugin(tauri_plugin_fs::init())
10 | .plugin(tauri_plugin_shell::init())
11 | .run(tauri::generate_context!())
12 | .expect("error while running tauri application");
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json",
3 | "build": {
4 | "beforeBuildCommand": "bun run build",
5 | "beforeDevCommand": "bun run dev",
6 | "frontendDist": "../dist",
7 | "devUrl": "http://localhost:3000"
8 | },
9 | "bundle": {
10 | "active": true,
11 | "category": "DeveloperTool",
12 | "copyright": "",
13 | "targets": "all",
14 | "externalBin": [],
15 | "icon": [
16 | "icons/32x32.png",
17 | "icons/128x128.png",
18 | "icons/128x128@2x.png",
19 | "icons/icon.icns",
20 | "icons/icon.ico"
21 | ],
22 | "windows": {
23 | "certificateThumbprint": null,
24 | "digestAlgorithm": "sha256",
25 | "timestampUrl": ""
26 | },
27 | "longDescription": "",
28 | "macOS": {
29 | "entitlements": null,
30 | "exceptionDomain": "",
31 | "frameworks": [],
32 | "providerShortName": null,
33 | "signingIdentity": null
34 | },
35 | "resources": [],
36 | "shortDescription": "",
37 | "linux": {
38 | "deb": {
39 | "depends": []
40 | }
41 | }
42 | },
43 | "productName": "Pyra UI",
44 | "mainBinaryName": "Pyra UI",
45 | "version": "4.2.2",
46 | "identifier": "pyra-ui",
47 | "plugins": {},
48 | "app": {
49 | "windows": [
50 | {
51 | "fullscreen": false,
52 | "resizable": true,
53 | "title": "Pyra UI",
54 | "height": 620,
55 | "minHeight": 620,
56 | "width": 1000,
57 | "minWidth": 1000,
58 | "center": true,
59 | "useHttpsScheme": true
60 | }
61 | ],
62 | "security": {
63 | "assetProtocol": {
64 | "scope": ["$DOWNLOAD/pyra/**", "$DOCUMENT/pyra/**", "$DOCUMENT/work/esm/pyra/**"],
65 | "enable": true
66 | },
67 | "csp": null
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/green-tum-logo_1024x.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src/assets/green-tum-logo_1024x.icns
--------------------------------------------------------------------------------
/packages/ui/src/assets/green-tum-logo_1024x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src/assets/green-tum-logo_1024x.png
--------------------------------------------------------------------------------
/packages/ui/src/assets/icons.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SVG = (props: { children: React.ReactNode; id?: string }) => (
4 |
14 |
15 | {props.children}
16 |
17 | );
18 |
19 | const ICONS = {
20 | refresh: (
21 |
22 |
23 |
24 |
25 | ),
26 | info: (
27 |
28 |
29 |
30 |
31 |
32 | ),
33 | spinner: (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ),
45 | check: (
46 |
47 |
48 |
49 |
50 | ),
51 | forbid: (
52 |
53 |
54 |
55 |
56 |
57 | ),
58 | close: (
59 |
60 |
61 |
62 |
63 | ),
64 | alert: (
65 |
66 |
67 |
68 |
69 | ),
70 | };
71 |
72 | export default ICONS;
73 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ICONS } from './icons';
2 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/index.ts:
--------------------------------------------------------------------------------
1 | import LabeledRow from './rows/labeled-row';
2 | import SavingOverlay from './saving-overlay';
3 | import ConfigElementText from './rows/config-element-text';
4 | import ConfigElementTime from './rows/config-element-time';
5 | import { ConfigElementToggle, ConfigElementBooleanToggle } from './rows/config-element-toggle';
6 | import ConfigSectionGeneral from './sections/config-section-general';
7 | import ConfigSectionErrorEmail from './sections/config-section-error-email';
8 | import ConfigSectionCamtracker from './sections/config-section-camtracker';
9 | import ConfigSectionMeasurementTriggers from './sections/config-section-measurement-triggers';
10 | import ConfigSectionOpus from './sections/config-section-opus';
11 | import ConfigSectionTUMEnclosure from './sections/config-section-tum-enclosure';
12 | import ConfigSectionHelios from './sections/config-section-helios';
13 | import ConfigSectionUpload from './sections/config-section-upload';
14 | import ConfigElementLine from './rows/config-element-line';
15 | import ConfigElementNote from './rows/config-element-note';
16 |
17 | export default {
18 | LabeledRow,
19 | SavingOverlay,
20 | ConfigElementText,
21 | ConfigElementTime,
22 | ConfigElementToggle,
23 | ConfigElementBooleanToggle,
24 | ConfigElementLine,
25 | ConfigElementNote,
26 | ConfigSectionCamtracker,
27 | ConfigSectionErrorEmail,
28 | ConfigSectionGeneral,
29 | ConfigSectionMeasurementTriggers,
30 | ConfigSectionOpus,
31 | ConfigSectionTUMEnclosure,
32 | ConfigSectionHelios,
33 | ConfigSectionUpload,
34 | };
35 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/rows/config-element-line.tsx:
--------------------------------------------------------------------------------
1 | const ConfigElementLine = () =>
;
2 |
3 | export default ConfigElementLine;
4 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/rows/config-element-note.tsx:
--------------------------------------------------------------------------------
1 | const ConfigElementNote = (props: { children: React.ReactNode }) => (
2 |
3 | {props.children}
4 |
5 | );
6 |
7 | export default ConfigElementNote;
8 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/rows/config-element-text.tsx:
--------------------------------------------------------------------------------
1 | import { configurationComponents, essentialComponents } from '../..';
2 | import toast from 'react-hot-toast';
3 | import * as dialog from '@tauri-apps/plugin-dialog';
4 | import * as shell from '@tauri-apps/plugin-shell';
5 |
6 | export default function ConfigElementText(props: {
7 | title: string;
8 | value: string | number;
9 | oldValue: string | number | undefined;
10 | setValue(v: string | number): void;
11 | disabled?: boolean;
12 | numeric?: boolean;
13 | postfix?: string;
14 | showSelector?: 'file' | 'directory';
15 | }) {
16 | const { title, value, oldValue, setValue, disabled, numeric, postfix, showSelector } = props;
17 |
18 | async function triggerSelection() {
19 | const result: any = await dialog.open({
20 | title: 'PyRa 4 UI',
21 | multiple: false,
22 | directory: showSelector === 'directory',
23 | });
24 | if (result !== null) {
25 | setValue(result);
26 | }
27 | }
28 |
29 | function parseNumericValue(v: string): string {
30 | const parsedValue = `${v}`.replace(/[^\d\.\+\-]/g, '');
31 | if (parsedValue === '') {
32 | return '0';
33 | }
34 | return parsedValue;
35 | }
36 |
37 | async function openDirectory() {
38 | if (typeof value === 'string') {
39 | let path: string;
40 | if (value.includes('\\')) {
41 | path = value.split('\\').slice(0, -1).join('\\');
42 | } else {
43 | path = value.split('/').slice(0, -1).join('/');
44 | }
45 | await shell.open(path).catch(() => toast.error('Could not open directory'));
46 | }
47 | }
48 |
49 | let hasBeenModified: boolean;
50 | if (numeric) {
51 | hasBeenModified = oldValue !== (typeof value === 'string' ? parseFloat(value) : value);
52 | } else {
53 | hasBeenModified = oldValue !== value;
54 | }
55 |
56 | return (
57 |
58 |
59 | setValue(numeric ? parseNumericValue(v) : v)}
62 | postfix={postfix}
63 | />
64 | {showSelector && !disabled && (
65 | <>
66 |
67 | select
68 |
69 |
70 | show in explorer
71 |
72 | >
73 | )}
74 |
75 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/rows/config-element-time.tsx:
--------------------------------------------------------------------------------
1 | import { configurationComponents, essentialComponents } from '../..';
2 | import { renderTimeObject } from '../../../utils/functions';
3 |
4 | export default function ConfigElementTime(props: {
5 | title: string;
6 | value: { hour: number; minute: number; second: number };
7 | oldValue: { hour: number; minute: number; second: number };
8 | setValue(v: { hour: number; minute: number; second: number }): void;
9 | disabled?: boolean;
10 | }) {
11 | const { title, value, oldValue, setValue, disabled } = props;
12 |
13 | function parseNumericValue(v: string): number {
14 | return parseInt(`${v}`.replace(/[^\d]/g, '')) || 0;
15 | }
16 |
17 | let hasBeenModified =
18 | value.hour !== oldValue.hour ||
19 | value.minute !== oldValue.minute ||
20 | value.second !== oldValue.second;
21 |
22 | return (
23 |
24 |
25 | setValue({ ...value, hour: parseNumericValue(v) })}
28 | disabled={disabled}
29 | />
30 | :
31 | setValue({ ...value, minute: parseNumericValue(v) })}
34 | disabled={disabled}
35 | />
36 | :
37 | setValue({ ...value, second: parseNumericValue(v) })}
40 | disabled={disabled}
41 | />
42 |
43 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/rows/config-element-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { configurationComponents, essentialComponents } from '../..';
2 |
3 | export function ConfigElementToggle(props: {
4 | title: string;
5 | value: string;
6 | values: string[];
7 | oldValue: string | null;
8 | setValue(v: string): void;
9 | }) {
10 | return (
11 |
15 | props.setValue(v)}
19 | />
20 |
29 |
30 | );
31 | }
32 |
33 | export function ConfigElementBooleanToggle(props: {
34 | title: string;
35 | value: boolean;
36 | oldValue: boolean | null;
37 | setValue(v: boolean): void;
38 | }) {
39 | return (
40 | props.setValue(v === 'yes')}
46 | />
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/rows/labeled-row.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function LabeledRow(props: {
4 | title: string;
5 | modified?: boolean;
6 | children: React.ReactNode;
7 | }) {
8 | const { title, modified, children } = props;
9 |
10 | return (
11 |
12 |
17 | {title}
18 | {modified && (
19 | modified
20 | )}
21 |
22 |
{children}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/saving-overlay.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '../ui/button';
2 |
3 | export default function SavingOverlay(props: {
4 | errorMessage: undefined | string;
5 | onSave(): void;
6 | onRevert(): void;
7 | isSaving: boolean;
8 | }) {
9 | return (
10 |
11 |
12 |
13 | {props.errorMessage === undefined && 'Save your changes now!'}
14 | {props.errorMessage !== undefined &&
15 | props.errorMessage.replaceAll('Error in ', '')}
16 |
17 |
18 |
19 |
20 | revert
21 |
22 |
23 | save
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/sections/config-section-general.tsx:
--------------------------------------------------------------------------------
1 | import { configurationComponents } from '../..';
2 | import { useConfigStore } from '../../../utils/zustand-utils/config-zustand';
3 |
4 | export default function ConfigSectionGeneral() {
5 | const { centralConfig, localConfig, setLocalConfigItem } = useConfigStore();
6 |
7 | const centralSectionConfig = centralConfig?.general;
8 | const localSectionConfig = localConfig?.general;
9 |
10 | if (localSectionConfig === undefined || centralSectionConfig === undefined) {
11 | return <>>;
12 | }
13 | return (
14 | <>
15 | setLocalConfigItem('general.station_id', v)}
19 | oldValue={centralSectionConfig.station_id}
20 | />
21 |
22 | Used in logs, emails and Helios images.
23 |
24 |
25 |
29 | setLocalConfigItem('general.seconds_per_core_iteration', v)
30 | }
31 | oldValue={centralSectionConfig.seconds_per_core_iteration}
32 | postfix="second(s)"
33 | numeric
34 | />
35 | setLocalConfigItem('general.min_sun_elevation', v)}
39 | oldValue={centralSectionConfig.min_sun_elevation}
40 | postfix="degree(s)"
41 | numeric
42 | />
43 |
44 | The EM27/SUN will power up three degrees below to warm up (only if enclosure is
45 | configured). Helios will start at this angle. Manual measurements will start at this
46 | angle.
47 |
48 |
49 | setLocalConfigItem('general.test_mode', v)}
53 | oldValue={centralSectionConfig.test_mode}
54 | />
55 |
56 | Only used in development. Otherwise left at `no`. If enabled, Pyra does not connect
57 | to the enclosure hardware, OPUS and CamTracker so it can run on any computer.
58 |
59 | >
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/packages/ui/src/components/configuration/sections/config-section-tum-enclosure.tsx:
--------------------------------------------------------------------------------
1 | import { configurationComponents, essentialComponents } from '../..';
2 | import { useConfigStore } from '../../../utils/zustand-utils/config-zustand';
3 | import { Button } from '../../ui/button';
4 |
5 | export default function ConfigSectionTUMEnclosure() {
6 | const { centralConfig, localConfig, setLocalConfigItem } = useConfigStore();
7 |
8 | const centralSectionConfig = centralConfig?.tum_enclosure;
9 | const localSectionConfig = localConfig?.tum_enclosure;
10 |
11 | function addDefault() {
12 | setLocalConfigItem('tum_enclosure', {
13 | ip: '10.10.0.4',
14 | version: 1,
15 | controlled_by_user: false,
16 | });
17 | }
18 |
19 | function setNull() {
20 | setLocalConfigItem('tum_enclosure', null);
21 | }
22 |
23 | if (localSectionConfig === undefined || centralSectionConfig === undefined) {
24 | return <>>;
25 | }
26 |
27 | if (localSectionConfig === null) {
28 | return (
29 |
30 |
set up now
31 |
40 | {centralSectionConfig !== null && (
41 |
42 | )}
43 |
44 | );
45 | }
46 |
47 | return (
48 | <>
49 |
50 | remove configuration
51 |
52 |
53 | setLocalConfigItem('tum_enclosure.ip', v)}
57 | oldValue={centralSectionConfig !== null ? centralSectionConfig.ip : 'null'}
58 | />
59 | setLocalConfigItem('tum_enclosure.version', v)}
63 | oldValue={centralSectionConfig !== null ? centralSectionConfig.version : 'null'}
64 | numeric
65 | />
66 | >
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Spinner from './spinner';
3 |
4 | export default function Button(props: {
5 | children: React.ReactNode;
6 | onClick(): void;
7 | variant: 'white' | 'green' | 'red' | 'gray' | 'flat-blue';
8 | disabled?: boolean;
9 | className?: string;
10 | dot?: boolean;
11 | spinner?: boolean;
12 | }) {
13 | let { children, onClick, variant, className, spinner, disabled } = props;
14 | if (spinner) {
15 | disabled = true;
16 | }
17 |
18 | let colorClasses: string = '';
19 | let dotColor: string = ' ';
20 | switch (variant) {
21 | case 'white':
22 | colorClasses = 'elevated-panel text-gray-800 hover:bg-gray-150 hover:text-gray-900 ';
23 | dotColor = 'bg-gray-300 ';
24 | break;
25 | case 'gray':
26 | colorClasses = 'elevated-panel ';
27 | if (props.disabled) {
28 | colorClasses += 'text-gray-400 !bg-gray-100 ';
29 | } else {
30 | colorClasses += 'text-gray-700 !bg-gray-75 hover:!bg-gray-200 hover:text-gray-950 ';
31 | }
32 | dotColor = 'bg-gray-300 ';
33 | break;
34 | case 'green':
35 | colorClasses = 'elevated-panel text-green-700 hover:bg-green-50 hover:text-green-900 ';
36 | dotColor = 'bg-green-300 ';
37 | break;
38 | case 'red':
39 | colorClasses = 'elevated-panel text-red-700 hover:bg-red-50 hover:text-red-900 ';
40 | dotColor = 'bg-red-300 ';
41 | break;
42 | case 'flat-blue':
43 | colorClasses =
44 | 'bg-green-100 rounded-md text-green-800 ' +
45 | 'hover:bg-blue-150 hover:text-blue-950 border border-green-300 ';
46 | dotColor = 'bg-blue-300 ';
47 | break;
48 | }
49 | return (
50 | {} : onClick}
53 | className={
54 | 'flex-row-center flex-shrink-0 px-4 h-8 ' +
55 | 'focus:outline-none focus:ring-1 focus:z-20 ' +
56 | 'focus:border-blue-500 focus:ring-blue-500 ' +
57 | 'text-sm whitespace-nowrap text-center font-medium ' +
58 | 'relative ' +
59 | colorClasses +
60 | (disabled ? ' cursor-not-allowed ' : ' cursor-pointer ') +
61 | className
62 | }
63 | >
64 | {props.dot && (
65 |
66 | )}
67 | {!spinner && children}
68 | {spinner && {children}
}
69 | {spinner && (
70 |
71 |
72 |
73 | )}
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/index.ts:
--------------------------------------------------------------------------------
1 | import Button from './button';
2 | import LiveSwitch from './live-switch';
3 | import { CoreLogLine, UILogLine } from './log-line';
4 | import NumericButton from './numeric-button';
5 | import Ping from './ping';
6 | import PreviousValue from './previous-value';
7 | import TextInput from './text-input';
8 | import Toggle from './toggle';
9 | import Spinner from './spinner';
10 |
11 | export default {
12 | Button,
13 | LiveSwitch,
14 | CoreLogLine,
15 | UILogLine,
16 | NumericButton,
17 | Ping,
18 | PreviousValue,
19 | TextInput,
20 | Toggle,
21 | Spinner,
22 | };
23 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/live-switch.tsx:
--------------------------------------------------------------------------------
1 | import { essentialComponents } from '..';
2 | export default function LiveSwitch(props: { isLive: boolean; toggle(v: boolean): void }) {
3 | const { isLive, toggle } = props;
4 |
5 | return (
6 | toggle(!isLive)}
8 | className={
9 | 'gap-x-2 ' +
10 | 'px-3 font-medium flex-row-center text-sm ' +
11 | 'elevated-panel h-8 hover:bg-gray-100 ' +
12 | (isLive ? 'text-gray-900' : 'text-gray-600')
13 | }
14 | >
15 |
16 | {isLive ? 'live' : 'paused'}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/numeric-button.tsx:
--------------------------------------------------------------------------------
1 | import { isNaN } from 'lodash';
2 | import React, { useState } from 'react';
3 | import Button from './button';
4 | import TextInput from './text-input';
5 |
6 | export default function NumericButton(props: {
7 | children: React.ReactNode;
8 | initialValue: number;
9 | onClick(value: number): void;
10 | disabled?: boolean;
11 | className?: string;
12 | spinner?: boolean;
13 | postfix?: string;
14 | }) {
15 | let { children, initialValue, onClick, className, spinner, disabled, postfix } = props;
16 | if (spinner) {
17 | disabled = true;
18 | }
19 |
20 | const [value, setValue] = useState(initialValue);
21 | return (
22 |
23 | setValue(isNaN(parseInt(v)) ? 0 : parseInt(v))}
26 | postfix={postfix}
27 | disabled={props.disabled}
28 | className="!h-8"
29 | />
30 | onClick(value)}
32 | variant="white"
33 | className="flex-grow"
34 | disabled={props.disabled}
35 | spinner={props.spinner}
36 | >
37 | {children}
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/ping.tsx:
--------------------------------------------------------------------------------
1 | export default function Ping(props: { state: boolean | undefined }) {
2 | let color = 'bg-gray-500';
3 | if (props.state === true) {
4 | color = 'bg-green-500';
5 | }
6 | if (props.state === false) {
7 | color = 'bg-red-500';
8 | }
9 | return (
10 |
11 | {props.state === true && (
12 |
13 | )}
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/previous-value.tsx:
--------------------------------------------------------------------------------
1 | export default function PreviousValue(props: { previousValue?: string | number | any }) {
2 | const { previousValue } = props;
3 | const sharedClasses1 = 'ml-1 text-xs font-normal flex-row-left opacity-80 gap-x-1';
4 | const sharedClasses2 =
5 | 'rounded-md bg-blue-100 border border-blue-300 px-1.5 py-0.5 text-blue-950 text-xs break-all';
6 |
7 | if (typeof previousValue === 'string' || typeof previousValue === 'number') {
8 | return (
9 |
10 | previous value:
11 | {previousValue}
12 |
13 | );
14 | } else if (typeof previousValue === 'object' && previousValue.length !== undefined) {
15 | return (
16 |
17 | previous value:
18 | {previousValue.map((v: any, i: number) => (
19 |
20 | {v}
21 |
22 | ))}
23 |
24 | );
25 | } else {
26 | return <>>;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { ICONS } from '../../assets';
2 |
3 | export default function Spinner() {
4 | return {ICONS.spinner}
;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/text-input.tsx:
--------------------------------------------------------------------------------
1 | export default function TextInput(props: {
2 | value: string;
3 | setValue(v: string): void;
4 | disabled?: boolean;
5 | postfix?: string | undefined;
6 | className?: string;
7 | }) {
8 | return (
9 |
14 |
props.setValue(e.target.value)}
18 | className={
19 | 'shadow-sm rounded-lg border-slate-300 text-sm w-full ' +
20 | 'focus:ring-blue-100 focus:border-blue-300 focus:ring ' +
21 | 'flex-grow h-9 ' +
22 | (props.disabled ? 'cursor-not-allowed bg-gray-100 ' : ' ') +
23 | (props.className ? props.className : '')
24 | }
25 | disabled={props.disabled}
26 | />
27 | {props.postfix !== undefined && (
28 |
33 | {props.value} {props.postfix}
34 |
35 | )}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/packages/ui/src/components/essential/toggle.tsx:
--------------------------------------------------------------------------------
1 | export default function Toggle(props: {
2 | value: string;
3 | setValue(v: string): void;
4 | values: string[];
5 | className?: string;
6 | }) {
7 | const { value, setValue, values, className } = props;
8 |
9 | return (
10 |
11 |
12 | {values.map((v) => (
13 | (value !== v ? setValue(v) : {})}
16 | className={
17 | 'first:rounded-l-lg last:rounded-r-lg ' +
18 | 'px-4 font-medium flex-row-center text-sm ' +
19 | (className !== undefined ? className : '') +
20 | ' ' +
21 | (value === v
22 | ? 'bg-slate-900 text-gray-50 border border-slate-900 -m-px z-10'
23 | : 'text-gray-500 hover:bg-gray-100 hover:text-gray-950 z-0')
24 | }
25 | >
26 | {v}
27 |
28 | ))}
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/ui/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as configurationComponents } from './configuration';
2 | export { default as essentialComponents } from './essential';
3 | export { default as overviewComponents } from './overview';
4 | export { default as structuralComponents } from './structural';
5 |
--------------------------------------------------------------------------------
/packages/ui/src/components/overview/index.ts:
--------------------------------------------------------------------------------
1 | import ActivityPlot from './activity-plot';
2 | import MeasurementDecision from './measurement-decision';
3 | import PyraCoreStatus from './pyra-core-status';
4 | import { SystemState, TumEnclosureState } from './system-state';
5 |
6 | export default {
7 | ActivityPlot,
8 | PyraCoreStatus,
9 | MeasurementDecision,
10 | SystemState,
11 | TumEnclosureState,
12 | };
13 |
--------------------------------------------------------------------------------
/packages/ui/src/components/structural/disconnected-screen.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { fetchUtils } from '../../utils';
3 | import { Button } from '../ui/button';
4 |
5 | const LinkToInstallationInstructions = (props: { projectDirPath: string }) => (
6 | <>
7 | Please follow the installation instructions on{' '}
8 | https://github.com/tum-esm/pyra .
9 | PYRA 4 should be located at "{props.projectDirPath}"
10 | >
11 | );
12 |
13 | export default function DisconnectedScreen(props: { retry: () => void }) {
14 | const [projectDirPath, setProjectDirPath] = useState('');
15 |
16 | useEffect(() => {
17 | async function init() {
18 | setProjectDirPath(await fetchUtils.getProjectDirPath());
19 | }
20 |
21 | init();
22 | }, []);
23 |
24 | return (
25 |
26 |
27 | PYRA has not been found on your system.{' '}
28 |
29 |
30 | try again
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/ui/src/components/structural/index.ts:
--------------------------------------------------------------------------------
1 | import { Header, BlankHeader } from './header';
2 | import DisconnectedScreen from './disconnected-screen';
3 |
4 | export default {
5 | Header,
6 | BlankHeader,
7 | DisconnectedScreen,
8 | };
9 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import { cn } from '../../lib/utils';
4 |
5 | const badgeVariants = cva(
6 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
7 | {
8 | variants: {
9 | variant: {
10 | default:
11 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
12 | secondary:
13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
14 | destructive:
15 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
16 | outline: 'text-foreground',
17 | },
18 | },
19 | defaultVariants: {
20 | variant: 'default',
21 | },
22 | }
23 | );
24 |
25 | export interface BadgeProps
26 | extends React.HTMLAttributes,
27 | VariantProps {}
28 |
29 | function Badge({ className, variant, ...props }: BadgeProps) {
30 | return
;
31 | }
32 |
33 | export { Badge, badgeVariants };
34 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '../../lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
15 | outline:
16 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
17 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
18 | ghost: 'hover:bg-accent hover:text-accent-foreground',
19 | link: 'text-primary underline-offset-4 hover:underline',
20 | },
21 | size: {
22 | default: 'h-9 px-4 py-2',
23 | sm: 'h-8 rounded-md px-3 text-xs',
24 | lg: 'h-10 rounded-md px-8',
25 | icon: 'h-9 w-9',
26 | },
27 | },
28 | defaultVariants: {
29 | variant: 'default',
30 | size: 'default',
31 | },
32 | }
33 | );
34 |
35 | export interface ButtonProps
36 | extends React.ButtonHTMLAttributes,
37 | VariantProps {
38 | asChild?: boolean;
39 | }
40 |
41 | const Button = React.forwardRef(
42 | ({ className, variant, size, asChild = false, ...props }, ref) => {
43 | const Comp = asChild ? Slot : 'button';
44 | return (
45 |
50 | );
51 | }
52 | );
53 | Button.displayName = 'Button';
54 |
55 | export { Button, buttonVariants };
56 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
3 | import { cn } from '../../lib/utils';
4 | import { CheckIcon } from '@radix-ui/react-icons';
5 |
6 | const Checkbox = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
21 |
22 |
23 |
24 | ));
25 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
26 |
27 | export { Checkbox };
28 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "../../lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/packages/ui/src/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client';
2 | import './styles/index.css';
3 | import Main from './main';
4 |
5 | // @ts-ignore
6 | const root = ReactDOM.createRoot(document.getElementById('root'));
7 | root.render( );
8 |
--------------------------------------------------------------------------------
/packages/ui/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { ICONS } from './assets';
3 | import { structuralComponents } from './components';
4 | import Dashboard from './components/structural/dashboard';
5 | import toast, { Toaster } from 'react-hot-toast';
6 | import backend from './utils/fetch-utils/backend';
7 |
8 | export default function Main() {
9 | const [backendIntegrity, setBackendIntegrity] = useState<
10 | undefined | 'valid' | 'cli is missing'
11 | >(undefined);
12 |
13 | useEffect(() => {
14 | load();
15 | }, []);
16 |
17 | function load() {
18 | if (backendIntegrity !== 'valid') {
19 | toast.promise(backend.pyraCliIsAvailable(), {
20 | loading: 'Connecting to Pyra backend',
21 | success: () => {
22 | setBackendIntegrity('valid');
23 | return 'Found Pyra on your system';
24 | },
25 | error: () => {
26 | setBackendIntegrity('cli is missing');
27 | return 'Could not find Pyra on your system';
28 | },
29 | });
30 | }
31 | }
32 |
33 | return (
34 |
35 | {backendIntegrity === undefined && (
36 | <>
37 |
38 |
39 | {ICONS.spinner}
40 |
41 | >
42 | )}
43 | {backendIntegrity === 'cli is missing' && (
44 |
45 | )}
46 | {backendIntegrity === 'valid' &&
}
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/packages/ui/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .svg-green-button path {
6 | @apply fill-slate-800;
7 | }
8 |
9 | .svg-toggle-true .primary {
10 | @apply fill-blue-300;
11 | }
12 |
13 | .svg-toggle-true .secondary {
14 | @apply fill-blue-900;
15 | }
16 |
17 | .svg-toggle-false .primary {
18 | @apply fill-gray-200;
19 | }
20 |
21 | .svg-toggle-false .secondary {
22 | @apply fill-transparent;
23 | }
24 |
25 | .flex-row-center {
26 | @apply flex flex-row items-center justify-center;
27 | }
28 |
29 | .flex-col-center {
30 | @apply flex flex-col items-center justify-center;
31 | }
32 |
33 | .flex-col-left {
34 | @apply flex flex-col items-start justify-center;
35 | }
36 |
37 | .flex-col-right {
38 | @apply flex flex-col items-end justify-center;
39 | }
40 |
41 | .flex-row-left {
42 | @apply flex flex-row items-center justify-start;
43 | }
44 |
45 | .flex-row-right {
46 | @apply flex flex-row items-center justify-end;
47 | }
48 |
49 | .flex-row-left-top {
50 | @apply flex flex-row items-start justify-start;
51 | }
52 |
53 | .elevated-panel {
54 | @apply shadow-sm rounded-lg bg-white border-gray-250 border;
55 | }
56 |
57 | @layer base {
58 | :root {
59 | --background: 0 0% 100%;
60 | --foreground: 222.2 84% 4.9%;
61 |
62 | --card: 0 0% 100%;
63 | --card-foreground: 222.2 84% 4.9%;
64 |
65 | --popover: 0 0% 100%;
66 | --popover-foreground: 222.2 84% 4.9%;
67 |
68 | --primary: 222.2 47.4% 11.2%;
69 | --primary-foreground: 210 40% 98%;
70 |
71 | --secondary: 210 40% 96.1%;
72 | --secondary-foreground: 222.2 47.4% 11.2%;
73 |
74 | --muted: 210 40% 96.1%;
75 | --muted-foreground: 215.4 16.3% 46.9%;
76 |
77 | --accent: 210 40% 96.1%;
78 | --accent-foreground: 222.2 47.4% 11.2%;
79 |
80 | --destructive: 0 84.2% 60.2%;
81 | --destructive-foreground: 210 40% 98%;
82 |
83 | --border: 214.3 31.8% 91.4%;
84 | --input: 214.3 31.8% 91.4%;
85 | --ring: 222.2 84% 4.9%;
86 |
87 | --radius: 0.5rem;
88 | }
89 |
90 | .dark {
91 | --background: 222.2 84% 4.9%;
92 | --foreground: 210 40% 98%;
93 |
94 | --card: 222.2 84% 4.9%;
95 | --card-foreground: 210 40% 98%;
96 |
97 | --popover: 222.2 84% 4.9%;
98 | --popover-foreground: 210 40% 98%;
99 |
100 | --primary: 210 40% 98%;
101 | --primary-foreground: 222.2 47.4% 11.2%;
102 |
103 | --secondary: 217.2 32.6% 17.5%;
104 | --secondary-foreground: 210 40% 98%;
105 |
106 | --muted: 217.2 32.6% 17.5%;
107 | --muted-foreground: 215 20.2% 65.1%;
108 |
109 | --accent: 217.2 32.6% 17.5%;
110 | --accent-foreground: 210 40% 98%;
111 |
112 | --destructive: 0 62.8% 30.6%;
113 | --destructive-foreground: 210 40% 98%;
114 |
115 | --border: 217.2 32.6% 17.5%;
116 | --input: 217.2 32.6% 17.5%;
117 | --ring: 212.7 26.8% 83.9%;
118 | }
119 | }
120 |
121 | @layer base {
122 | * {
123 | @apply border-border;
124 | }
125 | body {
126 | @apply bg-background text-foreground;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/packages/ui/src/tabs/index.ts:
--------------------------------------------------------------------------------
1 | export { default as OverviewTab } from './overview-tab';
2 | export { default as ConfigurationTab } from './configuration-tab';
3 | export { default as LogTab } from './log-tab';
4 | export { default as ControlTab } from './control-tab';
5 |
--------------------------------------------------------------------------------
/packages/ui/src/tabs/overview-tab.tsx:
--------------------------------------------------------------------------------
1 | import { fetchUtils } from '../utils';
2 | import { essentialComponents, overviewComponents } from '../components';
3 | import { useLogsStore } from '../utils/zustand-utils/logs-zustand';
4 | import { useConfigStore } from '../utils/zustand-utils/config-zustand';
5 | import { Button } from '../components/ui/button';
6 |
7 | export default function OverviewTab() {
8 | const { coreLogs } = useLogsStore();
9 | const { runPromisingCommand } = fetchUtils.useCommand();
10 | const { centralConfig } = useConfigStore();
11 |
12 | function closeCover() {
13 | runPromisingCommand({
14 | command: () => fetchUtils.backend.writeToTUMEnclosure(['close-cover']),
15 | label: 'closing cover',
16 | successLabel: 'successfully closed cover',
17 | });
18 | }
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
System State
31 |
32 | {centralConfig?.tum_enclosure && (
33 |
34 | force cover close
35 |
36 | )}
37 |
38 |
39 |
40 | Recent Logs
41 |
42 |
43 | {(coreLogs.all === undefined || coreLogs.all.length === 0) && (
44 |
45 |
46 |
47 | )}
48 | {coreLogs.all !== undefined &&
49 | coreLogs.all
50 | .filter((l) => !l.includes(' - DEBUG - '))
51 | .slice(-15)
52 | .map((l, i) => (
53 |
58 | ))}
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/fetch-utils/backend.ts:
--------------------------------------------------------------------------------
1 | import { Command, ChildProcess } from '@tauri-apps/plugin-shell';
2 | import { join } from '@tauri-apps/api/path';
3 | import fetchUtils from '.';
4 |
5 | async function callCLI(args: string[]): Promise> {
6 | let projectDirPath = await fetchUtils.getProjectDirPath();
7 | let pythonInterpreter =
8 | import.meta.env.VITE_PYTHON_INTERPRETER === 'venv' ? 'venv-python' : 'system-python';
9 | let pyraCLIEntrypoint = await join('packages', 'cli', 'main.py');
10 |
11 | const commandString = [pythonInterpreter, pyraCLIEntrypoint, ...args].join(' ');
12 | console.debug(`Running shell command: "${commandString}" in directory "${projectDirPath}"`);
13 |
14 | return new Promise(async (resolve, reject) => {
15 | Command.create(pythonInterpreter, [pyraCLIEntrypoint, ...args], {
16 | cwd: projectDirPath,
17 | })
18 | .execute()
19 | .then((result) => {
20 | if (result.code === 0) {
21 | console.debug('CLI command executed successfully');
22 | resolve(result);
23 | } else {
24 | console.error('Error when calling CLI: ', result);
25 | reject(result);
26 | }
27 | })
28 | .catch((error) => {
29 | console.error('Error when calling CLI: ', error);
30 | reject(error);
31 | });
32 | });
33 | }
34 |
35 | const backend = {
36 | pyraCliIsAvailable: async (): Promise> => {
37 | return await callCLI([]);
38 | },
39 | checkPyraCoreState: async (): Promise> => {
40 | return await callCLI(['core', 'is-running']);
41 | },
42 | startPyraCore: async (): Promise> => {
43 | return await callCLI(['core', 'start']);
44 | },
45 | stopPyraCore: async (): Promise> => {
46 | return await callCLI(['core', 'stop']);
47 | },
48 | getConfig: async (): Promise> => {
49 | return await callCLI(['config', 'get', '--no-indent', '--no-color']);
50 | },
51 | updateConfig: async (newConfig: any): Promise> => {
52 | return await callCLI(['config', 'update', JSON.stringify(newConfig)]);
53 | },
54 | writeToTUMEnclosure: async (command: string[]): Promise> => {
55 | return await callCLI(['tum-enclosure', ...command]);
56 | },
57 | testOpus: async (): Promise> => {
58 | return await callCLI(['test', 'opus']);
59 | },
60 | testCamTracker: async (): Promise> => {
61 | return await callCLI(['test', 'camtracker']);
62 | },
63 | testEmail: async (): Promise> => {
64 | return await callCLI(['test', 'email']);
65 | },
66 | testUpload: async (): Promise> => {
67 | return await callCLI(['test', 'upload']);
68 | },
69 | };
70 |
71 | export default backend;
72 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/fetch-utils/get-file-content.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from '@tauri-apps/plugin-fs';
2 | import { BaseDirectory, join } from '@tauri-apps/api/path';
3 | import { split, tail } from 'lodash';
4 |
5 | async function getFileContent(filePath: string): Promise {
6 | let absoluteFilePath: string;
7 |
8 | if (import.meta.env.VITE_PYRA_DIRECTORY !== undefined) {
9 | absoluteFilePath = await join(
10 | ...tail(split(import.meta.env.VITE_PYRA_DIRECTORY, '/Documents/')),
11 | ...filePath.split('/')
12 | );
13 | } else {
14 | absoluteFilePath = await join('pyra', `pyra-${APP_VERSION}`, ...filePath.split('/'));
15 | }
16 |
17 | console.debug(`Reading file: "${absoluteFilePath}" in ~/Documents`);
18 | return new TextDecoder('utf-8').decode(
19 | await readFile(absoluteFilePath, { baseDir: BaseDirectory.Document })
20 | );
21 | }
22 |
23 | export default getFileContent;
24 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/fetch-utils/get-project-dir-path.ts:
--------------------------------------------------------------------------------
1 | import { documentDir, join } from '@tauri-apps/api/path';
2 | import { split, tail } from 'lodash';
3 |
4 | async function getProjectDirPath() {
5 | if (import.meta.env.VITE_PYRA_DIRECTORY !== undefined) {
6 | return await join(
7 | await documentDir(),
8 | ...tail(split(import.meta.env.VITE_PYRA_DIRECTORY, '/Documents/'))
9 | );
10 | } else {
11 | return await join(await documentDir(), 'pyra', `pyra-${APP_VERSION}`);
12 | }
13 | }
14 |
15 | export default getProjectDirPath;
16 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/fetch-utils/index.ts:
--------------------------------------------------------------------------------
1 | import backend from './backend';
2 | import getFileContent from './get-file-content';
3 | import getProjectDirPath from './get-project-dir-path';
4 | import useCommand from './use-command';
5 |
6 | export default {
7 | backend,
8 | getFileContent,
9 | getProjectDirPath,
10 | useCommand,
11 | };
12 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/fetch-utils/use-command.ts:
--------------------------------------------------------------------------------
1 | import { ChildProcess } from '@tauri-apps/plugin-shell';
2 | import { useLogsStore } from '../zustand-utils/logs-zustand';
3 | import toast from 'react-hot-toast';
4 | import { useState } from 'react';
5 |
6 | export default function useCommand() {
7 | const { addUiLogLine } = useLogsStore();
8 | const [commandIsRunning, setCommandIsRunning] = useState(false);
9 |
10 | function exitFromFailedProcess(p: ChildProcess, label: string): string {
11 | addUiLogLine(
12 | `Error while ${label}.`,
13 | `stdout: "${p.stdout.trim()}"\nstderr: "${p.stderr.trim()}"`
14 | );
15 | return `Error while ${label}, full error in UI logs`;
16 | }
17 |
18 | function runPromisingCommand(args: {
19 | command: () => Promise>;
20 | label: string;
21 | successLabel: string;
22 | onSuccess?: (p: ChildProcess) => void;
23 | onError?: (p: ChildProcess) => void;
24 | }): void {
25 | if (commandIsRunning) {
26 | toast.error('Cannot run multiple commands at the same time');
27 | } else {
28 | setCommandIsRunning(true);
29 | toast.promise(args.command(), {
30 | loading: args.label,
31 | success: (p: ChildProcess) => {
32 | if (args.onSuccess) {
33 | args.onSuccess(p);
34 | }
35 | setCommandIsRunning(false);
36 | return args.successLabel;
37 | },
38 | error: (p: ChildProcess) => {
39 | if (args.onError) {
40 | args.onError(p);
41 | }
42 | setCommandIsRunning(false);
43 | return exitFromFailedProcess(p, args.label);
44 | },
45 | });
46 | }
47 | }
48 |
49 | return {
50 | commandIsRunning,
51 | runPromisingCommand,
52 | };
53 | }
54 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/functions.ts:
--------------------------------------------------------------------------------
1 | export function renderString(
2 | value: undefined | null | string | number,
3 | options?: { appendix: string }
4 | ) {
5 | if (value === undefined || value === null) {
6 | return '-';
7 | } else {
8 | return `${value}${options !== undefined ? options.appendix : ''}`;
9 | }
10 | }
11 |
12 | export function renderBoolean(value: undefined | null | boolean) {
13 | if (value === undefined || value === null) {
14 | return '-';
15 | } else {
16 | return value ? 'Yes' : 'No';
17 | }
18 | }
19 |
20 | export function renderNumber(
21 | value: undefined | null | string | number,
22 | options?: { appendix: string }
23 | ) {
24 | if (value === undefined || value === null) {
25 | return '-';
26 | } else {
27 | return `${Number(value).toFixed(2)}${options !== undefined ? options.appendix : ''}`;
28 | }
29 | }
30 |
31 | export function renderTimeObject(value: { hour: number; minute: number; second: number }) {
32 | const hourString = `${value.hour}`.padStart(2, '0');
33 | const minuteString = `${value.minute}`.padStart(2, '0');
34 | const secondString = `${value.second}`.padStart(2, '0');
35 | return `${hourString}:${minuteString}:${secondString}`;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { default as fetchUtils } from './fetch-utils';
2 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/zustand-utils/activity-zustand.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { z } from 'zod';
3 |
4 | export const MINUTES_PER_BIN = 5;
5 |
6 | const activityHistorySchema = z.object({
7 | is_running: z.array(z.number()).length(24 * 60),
8 | is_measuring: z.array(z.number()).length(24 * 60),
9 | has_errors: z.array(z.number()).length(24 * 60),
10 | camtracker_startups: z.array(z.number()).length(24 * 60),
11 | opus_startups: z.array(z.number()).length(24 * 60),
12 | cli_calls: z.array(z.number()).length(24 * 60),
13 | is_uploading: z.array(z.number()).length(24 * 60),
14 | });
15 | export type ActivityHistory = z.infer;
16 |
17 | export type ActivitySection = {
18 | from_minute_index: number;
19 | to_minute_index: number;
20 | count: number;
21 | };
22 |
23 | function parseActivityHistoryTimeSeries(ts: number[]): ActivitySection[] {
24 | const smallerTs: number[] = [];
25 | for (let i = 0; i < ts.length; i++) {
26 | if (i % MINUTES_PER_BIN === 0) {
27 | smallerTs.push(ts[i]);
28 | } else {
29 | smallerTs[smallerTs.length - 1] += ts[i];
30 | }
31 | }
32 | const sections: ActivitySection[] = [];
33 | let currentSection: ActivitySection | undefined = undefined;
34 | for (let i = 0; i < smallerTs.length; i++) {
35 | if (smallerTs[i] > 0) {
36 | if (!currentSection) {
37 | currentSection = {
38 | from_minute_index: i * MINUTES_PER_BIN,
39 | to_minute_index: i * MINUTES_PER_BIN,
40 | count: smallerTs[i],
41 | };
42 | } else {
43 | currentSection.to_minute_index = i * MINUTES_PER_BIN;
44 | currentSection.count += smallerTs[i];
45 | }
46 | } else {
47 | if (currentSection) {
48 | sections.push(currentSection);
49 | currentSection = undefined;
50 | }
51 | }
52 | }
53 | if (currentSection) {
54 | sections.push(currentSection);
55 | }
56 | return sections;
57 | }
58 |
59 | interface ActivityHistoryStore {
60 | activitySections: {
61 | is_running: ActivitySection[];
62 | is_measuring: ActivitySection[];
63 | has_errors: ActivitySection[];
64 | camtracker_startups: ActivitySection[];
65 | opus_startups: ActivitySection[];
66 | cli_calls: ActivitySection[];
67 | is_uploading: ActivitySection[];
68 | };
69 |
70 | setActivityHistory: (ah: any) => void;
71 | }
72 |
73 | export const useActivityHistoryStore = create()((set) => ({
74 | activitySections: {
75 | is_running: [],
76 | is_measuring: [],
77 | has_errors: [],
78 | camtracker_startups: [],
79 | opus_startups: [],
80 | cli_calls: [],
81 | is_uploading: [],
82 | },
83 | setActivityHistory: (ah: string) => {
84 | const parsed = activityHistorySchema.parse(ah);
85 | set(() => ({
86 | activitySections: {
87 | is_running: parseActivityHistoryTimeSeries(parsed.is_running),
88 | is_measuring: parseActivityHistoryTimeSeries(parsed.is_measuring),
89 | has_errors: parseActivityHistoryTimeSeries(parsed.has_errors),
90 | camtracker_startups: parseActivityHistoryTimeSeries(parsed.camtracker_startups),
91 | opus_startups: parseActivityHistoryTimeSeries(parsed.opus_startups),
92 | cli_calls: parseActivityHistoryTimeSeries(parsed.cli_calls),
93 | is_uploading: parseActivityHistoryTimeSeries(parsed.is_uploading),
94 | },
95 | }));
96 | },
97 | }));
98 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/zustand-utils/core-process-zustand.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | interface CoreProcessStore {
4 | pyraCorePid: number | undefined;
5 | setPyraCorePid: (pid: number | undefined) => void;
6 | }
7 |
8 | export const useCoreProcessStore = create()((set) => ({
9 | pyraCorePid: undefined,
10 | setPyraCorePid: (pid) => set(() => ({ pyraCorePid: pid })),
11 | }));
12 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/zustand-utils/core-state-zustand.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { z } from 'zod';
3 | import { set as lodashSet } from 'lodash';
4 |
5 | const coreStateSchema = z.object({
6 | position: z.object({
7 | latitude: z.number().nullable(),
8 | longitude: z.number().nullable(),
9 | altitude: z.number().nullable(),
10 | sun_elevation: z.number().nullable(),
11 | }),
12 | measurements_should_be_running: z.boolean().nullable(),
13 | tum_enclosure_state: z.object({
14 | last_full_fetch: z.string().nullable(),
15 | actors: z.object({
16 | fan_speed: z.number().nullable(),
17 | current_angle: z.number().nullable(),
18 | }),
19 | control: z.object({
20 | auto_temp_mode: z.boolean().nullable(),
21 | manual_control: z.boolean().nullable(),
22 | manual_temp_mode: z.boolean().nullable(),
23 | sync_to_tracker: z.boolean().nullable(),
24 | }),
25 | sensors: z.object({
26 | humidity: z.number().nullable(),
27 | temperature: z.number().nullable(),
28 | }),
29 | state: z.object({
30 | cover_closed: z.boolean().nullable(),
31 | motor_failed: z.boolean().nullable(),
32 | rain: z.boolean().nullable(),
33 | reset_needed: z.boolean().nullable(),
34 | ups_alert: z.boolean().nullable(),
35 | }),
36 | power: z.object({
37 | camera: z.boolean().nullable(),
38 | computer: z.boolean().nullable(),
39 | heater: z.boolean().nullable(),
40 | router: z.boolean().nullable(),
41 | spectrometer: z.boolean().nullable(),
42 | }),
43 | connections: z.object({
44 | camera: z.boolean().nullable(),
45 | computer: z.boolean().nullable(),
46 | heater: z.boolean().nullable(),
47 | router: z.boolean().nullable(),
48 | spectrometer: z.boolean().nullable(),
49 | }),
50 | }),
51 | operating_system_state: z.object({
52 | cpu_usage: z.array(z.number()).nullable(),
53 | memory_usage: z.number().nullable(),
54 | last_boot_time: z.string().nullable(),
55 | filled_disk_space_fraction: z.number().nullable(),
56 | }),
57 | });
58 |
59 | export type CoreState = z.infer;
60 |
61 | interface CoreStateStore {
62 | coreState: CoreState | undefined;
63 | setCoreState: (s: any) => void;
64 | setCoreStateItem: (path: string, value: any) => void;
65 | }
66 |
67 | export const useCoreStateStore = create()((set) => ({
68 | coreState: undefined,
69 | setCoreState: (s: any) => set(() => ({ coreState: coreStateSchema.parse(s) })),
70 | setCoreStateItem: (path: string, value: any) =>
71 | set((state) => {
72 | if (state.coreState === undefined) {
73 | return {};
74 | }
75 | return {
76 | coreState: lodashSet(state.coreState, path, value),
77 | };
78 | }),
79 | }));
80 |
--------------------------------------------------------------------------------
/packages/ui/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare const APP_VERSION: string;
4 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "esnext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.mjs"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/vite.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | define: {
8 | APP_VERSION: JSON.stringify(process.env.npm_package_version),
9 | },
10 | build: {
11 | chunkSizeWarningLimit: 1024,
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "pyra"
3 | version = "4.2.2"
4 | description = "Automated EM27/SUN Greenhouse Gas Measurements"
5 | authors = [
6 | { name = "Moritz Makowski", email = "moritz.makowski@tum.de" },
7 | { name = "Patrick Aigner", email = "patrick.aigner@tum.de" },
8 | { name = "Andreas Luther", email = "andreas.luther@tum.de" },
9 | { name = "Friedrich Klappenbach", email = "friedrich.klappenbach@tum.de" },
10 | ]
11 | dependencies = [
12 | "numpy>=2.2.5",
13 | "click==8.1.8",
14 | "python-snap7==2.0.2",
15 | "jdcal==1.4.1",
16 | "tqdm>=4.67.1",
17 | "colorama>=0.4.6",
18 | "deepdiff>=8.4.2",
19 | "skyfield>=1.53",
20 | "circadian-scp-upload>=0.5.2",
21 | "setuptools>=79.0.0",
22 | "pydantic>=2.11.3",
23 | "filelock>=3.18.0",
24 | "psutil>=7.0.0",
25 | "scikit-image>=0.25.2",
26 | "pillow>=11.2.1",
27 | "requests>=2.32.3",
28 | "opencv-python>=4.11.0.86",
29 | "tum-esm-utils[opus]>=2.7.2",
30 | ]
31 | requires-python = "==3.10.*"
32 | readme = "README.md"
33 | license = { text = "GPL-3" }
34 |
35 | [project.optional-dependencies]
36 | dev = [
37 | "lazydocs>=0.4.8",
38 | "mypy>=1.8.0",
39 | "pytest>=7.4.4",
40 | "pytest-cov>=4.1.0",
41 | "pytest-order>=1.2.1",
42 | "ruff>=0.8.2",
43 | "types-psutil>=5.9.5.20240106",
44 | "types-paramiko>=3.4.0.20240106",
45 | "types-invoke>=2.0.0.10",
46 | "types-requests>=2.32.0.20241016",
47 | ]
48 |
49 | [tool.ruff]
50 | line-length = 100
51 |
52 | [tool.ruff.lint]
53 | ignore = ["E402", "E741"]
54 | exclude = ["tests/*"]
55 |
56 | [tool.mypy]
57 | strict = true
58 | implicit_reexport = true
59 | warn_unused_ignores = false
60 | untyped_calls_exclude = ["skimage"]
61 | plugins = ["pydantic.mypy"]
62 |
63 | [[tool.mypy.overrides]]
64 | module = [
65 | "cv2",
66 | "jdcal",
67 | "deepdiff",
68 | "psutil",
69 | "skyfield.*",
70 | "fabric.*",
71 | "brukeropus",
72 | "brukeropus.*",
73 | "skimage.*",
74 | ]
75 | ignore_missing_imports = true
76 |
77 | [tool.pytest.ini_options]
78 | filterwarnings = [
79 | 'ignore:the imp module is deprecated in favour of importlib:DeprecationWarning',
80 | 'ignore:pkg_resources is deprecated as an API:DeprecationWarning',
81 | 'ignore:getargs:DeprecationWarning',
82 | ]
83 | markers = [
84 | "ci: can be run in a CI environment",
85 | "integration: can only be run on a configured system",
86 | ]
87 |
88 | [tool.pdm.options]
89 | add = ["--no-self"]
90 | install = ["--no-self"]
91 | remove = ["--no-self"]
92 |
93 | [tool.pdm]
94 | distribution = false
95 |
96 | [tool.setuptools]
97 | packages = []
98 |
--------------------------------------------------------------------------------
/run_pyra_core.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import filelock
4 | import tum_esm_utils
5 |
6 | from packages.core import main
7 |
8 | _run_pyra_core_lock = filelock.FileLock(
9 | os.path.join(
10 | tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=2),
11 | "run_pyra_core.lock",
12 | ),
13 | timeout=0.5,
14 | )
15 |
16 | if __name__ == "__main__":
17 | try:
18 | _run_pyra_core_lock.acquire()
19 | try:
20 | main.run()
21 | finally:
22 | _run_pyra_core_lock.release()
23 | except filelock.Timeout:
24 | print("Aborting start: core process is already running")
25 |
--------------------------------------------------------------------------------
/scripts/run_headless_helios_thread.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import tum_esm_utils
3 |
4 | sys.path.append(tum_esm_utils.files.rel_to_abs_path(".."))
5 |
6 | from packages.core import threads
7 |
8 | if __name__ == "__main__":
9 | threads.HeliosThread.main(headless=True)
10 |
--------------------------------------------------------------------------------
/scripts/run_headless_upload_thread.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import tum_esm_utils
3 |
4 | sys.path.append(tum_esm_utils.files.rel_to_abs_path(".."))
5 |
6 | from packages.core import threads
7 |
8 | if __name__ == "__main__":
9 | threads.UploadThread.main(headless=True)
10 |
--------------------------------------------------------------------------------
/scripts/sync_version_numbers.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 | dir = os.path.dirname
5 | PROJECT_DIR = dir(dir(os.path.abspath(__file__)))
6 |
7 | TOML_PATH = os.path.join(PROJECT_DIR, "pyproject.toml")
8 | PACKAGE_JSON_PATH = os.path.join(PROJECT_DIR, "packages", "ui", "package.json")
9 | TAURI_JSON_PATH = os.path.join(PROJECT_DIR, "packages", "ui", "src-tauri", "tauri.conf.json")
10 |
11 | # load version number
12 | with open(TOML_PATH, "r") as f:
13 | while True:
14 | line = f.readline()
15 | if not line.startswith("version"):
16 | continue
17 | version = line.split("=")[1].replace('"', "").replace(" ", "").replace("\n", "")
18 | break
19 |
20 | # update package.json
21 | with open(PACKAGE_JSON_PATH, "r") as f:
22 | json_content = json.load(f)
23 | json_content["version"] = version
24 | with open(PACKAGE_JSON_PATH, "w") as f:
25 | json.dump(json_content, f, indent=4)
26 |
27 | # update tauri.conf.json
28 | with open(TAURI_JSON_PATH, "r") as f:
29 | json_content = json.load(f)
30 | json_content["package"]["version"] = version
31 | with open(TAURI_JSON_PATH, "w") as f:
32 | json.dump(json_content, f, indent=4)
33 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/__init__.py
--------------------------------------------------------------------------------
/tests/cli/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/cli/__init__.py
--------------------------------------------------------------------------------
/tests/integration/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/integration/__init__.py
--------------------------------------------------------------------------------
/tests/integration/test_camtracker_connection.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from packages.core import types, threads, utils
3 |
4 |
5 | @pytest.mark.order(3)
6 | @pytest.mark.integration
7 | def test_camtracker_connection() -> None:
8 | config = types.Config.load()
9 | logger = utils.Logger(origin="camtracker", just_print=True)
10 | threads.camtracker_thread.CamTrackerThread.test_setup(config, logger)
11 |
--------------------------------------------------------------------------------
/tests/integration/test_config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import pytest
4 | import tum_esm_utils
5 |
6 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3)
7 | CONFIG_DIR = os.path.join(PROJECT_DIR, "config")
8 |
9 | sys.path.append(PROJECT_DIR)
10 | from packages.core import types
11 |
12 |
13 | @pytest.mark.order(1)
14 | @pytest.mark.integration
15 | def test_config() -> None:
16 | types.Config.load()
17 |
--------------------------------------------------------------------------------
/tests/integration/test_emailing.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from packages.core import utils, types
3 |
4 |
5 | @pytest.mark.order(2)
6 | @pytest.mark.integration
7 | def test_emailing() -> None:
8 | config = types.Config.load()
9 | utils.ExceptionEmailClient.send_test_email(config)
10 |
--------------------------------------------------------------------------------
/tests/integration/test_opus_connection.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from packages.core import types, threads, utils
3 |
4 |
5 | @pytest.mark.order(3)
6 | @pytest.mark.integration
7 | def test_opus_connection() -> None:
8 | config = types.Config.load()
9 | logger = utils.Logger(origin="opus", just_print=True)
10 | try:
11 | threads.OpusThread.test_setup(config, logger)
12 | finally:
13 | threads.opus_thread.OpusProgram.stop(logger)
14 |
--------------------------------------------------------------------------------
/tests/integration/test_uploading.py:
--------------------------------------------------------------------------------
1 | import circadian_scp_upload
2 | import pytest
3 | from packages.core import types
4 |
5 |
6 | @pytest.mark.order(2)
7 | @pytest.mark.integration
8 | def test_uploading() -> None:
9 | config = types.Config.load()
10 | if config.upload is None:
11 | return
12 |
13 | with circadian_scp_upload.RemoteConnection(
14 | config.upload.host.root,
15 | config.upload.user,
16 | config.upload.password,
17 | ) as remote_connection:
18 | if remote_connection.connection.is_connected:
19 | return
20 |
21 | raise Exception("`circadian_scp_upload` should have raised an error")
22 |
--------------------------------------------------------------------------------
/tests/repository/README.md:
--------------------------------------------------------------------------------
1 | Tests for the repositories state.
2 |
3 | 1. Are the default configs in a valid state (all validation rules except paths)
4 | 2. Is the static type analysis running through?
5 | 3. ...
6 |
7 | These tests can be run in a CI environment
8 |
--------------------------------------------------------------------------------
/tests/repository/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/repository/__init__.py
--------------------------------------------------------------------------------
/tests/repository/test_default_config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import pytest
4 | import tum_esm_utils
5 |
6 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3)
7 | CONFIG_DIR = os.path.join(PROJECT_DIR, "config")
8 |
9 | sys.path.append(PROJECT_DIR)
10 | from packages.core import types
11 |
12 |
13 | @pytest.mark.order(2)
14 | @pytest.mark.ci
15 | def test_default_config() -> None:
16 | with open(os.path.join(CONFIG_DIR, "config.default.json"), "r") as f:
17 | types.Config.load(f.read(), ignore_path_existence=True)
18 |
19 | with open(os.path.join(CONFIG_DIR, "tum_enclosure.config.default.json"), "r") as f:
20 | types.enclosures.tum_enclosure.TUMEnclosureConfig.model_validate_json(f.read())
21 |
22 | with open(os.path.join(CONFIG_DIR, "helios.config.default.json"), "r") as f:
23 | types.config.HeliosConfig.model_validate_json(f.read())
24 |
25 | with open(os.path.join(CONFIG_DIR, "upload.config.default.json"), "r") as f:
26 | types.config.UploadConfig.model_validate_json(
27 | f.read(),
28 | context={"ignore-path-existence": True},
29 | )
30 |
--------------------------------------------------------------------------------
/tests/repository/test_static_types.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import pytest
4 | import tum_esm_utils
5 |
6 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3)
7 |
8 |
9 | def _rmdir(path: str) -> None:
10 | path = os.path.join(PROJECT_DIR, path)
11 | if os.path.isdir(path):
12 | shutil.rmtree(path)
13 |
14 |
15 | def _rm(path: str) -> None:
16 | path = os.path.join(PROJECT_DIR, path)
17 | os.system(f"rm -rf {path}")
18 |
19 |
20 | @pytest.mark.order(1)
21 | @pytest.mark.ci
22 | def test_static_types() -> None:
23 | _rmdir(".mypy_cache/3.10/packages")
24 | _rmdir(".mypy_cache/3.10/tests")
25 | _rm(".mypy_cache/3.10/run_pyra_core.*")
26 |
27 | for path in [
28 | "run_pyra_core.py",
29 | "packages/cli/main.py",
30 | "tests/",
31 | ]:
32 | assert os.system(f"cd {PROJECT_DIR} && python -m mypy {path}") == 0
33 |
--------------------------------------------------------------------------------
/tests/repository/test_version_numbers.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 | import pytest
5 | import tum_esm_utils
6 |
7 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3)
8 |
9 |
10 | @pytest.mark.order(2)
11 | @pytest.mark.ci
12 | def test_version_numbers() -> None:
13 | with open(os.path.join(PROJECT_DIR, "pyproject.toml"), "r") as f:
14 | third_line = f.read().split("\n")[2]
15 | assert third_line.startswith("version = ")
16 | pyproject_version = third_line.split(" = ")[1].strip('"')
17 |
18 | cli_info_stdout = tum_esm_utils.shell.run_shell_command(
19 | f"{sys.executable} ./packages/cli/main.py info", working_directory=PROJECT_DIR
20 | ).strip(" \n")
21 | assert (
22 | cli_info_stdout
23 | == f'This CLI is running Pyra version {pyproject_version} in directory "{PROJECT_DIR}"'
24 | )
25 |
26 | with open(os.path.join(PROJECT_DIR, "config", "config.default.json"), "r") as f:
27 | default_config_version = json.load(f)["general"]["version"]
28 | assert default_config_version == pyproject_version
29 |
30 | with open(os.path.join(PROJECT_DIR, "packages", "ui", "package.json"), "r") as f:
31 | ui_package_version = json.load(f)["version"]
32 | assert ui_package_version == pyproject_version
33 |
34 | with open(os.path.join(PROJECT_DIR, "packages", "ui", "src-tauri", "Cargo.toml"), "r") as f:
35 | third_line = f.read().split("\n")[2]
36 | assert third_line.startswith("version = ")
37 | cargo_version = third_line.split(" = ")[1].strip('"')
38 | assert cargo_version == pyproject_version
39 |
40 | with open(
41 | os.path.join(PROJECT_DIR, "packages", "ui", "src-tauri", "tauri.conf.json"), "r"
42 | ) as f:
43 | tauri_conf_version = json.load(f)["version"]
44 | assert tauri_conf_version == pyproject_version
45 |
--------------------------------------------------------------------------------
/tests/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/utils/__init__.py
--------------------------------------------------------------------------------
/tests/utils/test_astronomy.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | import random
4 | import sys
5 | import tempfile
6 | import pytest
7 | import tum_esm_utils
8 | from ..fixtures import sample_config
9 |
10 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3)
11 | CONFIG_DIR = os.path.join(PROJECT_DIR, "config")
12 |
13 | sys.path.append(PROJECT_DIR)
14 | from packages.core import types, utils
15 |
16 |
17 | @pytest.mark.order(3)
18 | @pytest.mark.ci
19 | def test_astronomy(sample_config: types.Config) -> None:
20 | # disable test mode because in test mode it uses
21 | # munich coordinates instead of reading them from
22 | # the CamTracker config file
23 | sample_config.general.test_mode = False
24 |
25 | try:
26 | utils.Astronomy.get_current_sun_elevation(sample_config)
27 | raise Exception("Failed to warn about not loaded astronomy data.")
28 | except AssertionError:
29 | pass
30 |
31 | utils.Astronomy.load_astronomical_dataset()
32 |
33 | # use tmp camtracker config file with munich coordinates at $1 marker
34 | tmp_filename = tempfile.NamedTemporaryFile(delete=True).name
35 | with open(tmp_filename, "w") as f:
36 | f.write("$1\n")
37 | f.write("48.137154\n")
38 | f.write("11.576124\n")
39 | f.write("515\n")
40 | sample_config.camtracker.config_path.root = tmp_filename
41 |
42 | # test whether elevation is correctly computed using the config file
43 | e1 = utils.Astronomy.get_current_sun_elevation(sample_config)
44 | assert isinstance(e1, float)
45 |
46 | # test whether coordinates are correctly read from camtracker config file
47 | e2 = utils.Astronomy.get_current_sun_elevation(
48 | sample_config, lat=48.137154, lon=11.576124, alt=515
49 | )
50 | assert isinstance(e2, float)
51 | assert abs(e1 - e2) < 1e-2
52 |
53 | # generate a random datetime for every year between 2020 and 2050
54 | # and test whether the elevation is correctly computed
55 | for year in range(2020, 2051):
56 | e = utils.Astronomy.get_current_sun_elevation(
57 | sample_config,
58 | datetime_object=datetime.datetime(
59 | year=year,
60 | month=random.randint(1, 12),
61 | day=random.randint(1, 28),
62 | hour=random.randint(0, 23),
63 | minute=random.randint(0, 59),
64 | second=random.randint(0, 59),
65 | ),
66 | )
67 | assert isinstance(e, float)
68 | assert -90 <= e <= 90
69 |
--------------------------------------------------------------------------------