From 67e546921bb105cd15345235d2662543b791dc3d Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:49:04 +0200 Subject: [PATCH 01/61] refresh the CI a bit --- .github/ISSUE_TEMPLATE/custom.md | 27 ---------- .github/ISSUE_TEMPLATE/feature_request.md | 20 -------- .github/workflows/CompatHelper.yml | 3 +- .github/workflows/ci.yml | 25 ++++----- .github/workflows/docker.yml | 50 +++++++++++------- .github/workflows/docs.yml | 14 +++-- .github/workflows/pr-format.yml | 62 +++++++++++++++++++++++ 7 files changed, 112 insertions(+), 89 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/custom.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/pr-format.yml diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index b223dd03..00000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: General issue (performance or bug) -about: Describe your issue related to performance or a bug -title: '' -labels: '' -assignees: '' - ---- - -**Summary** - -**Environment** - -*Please provide all information on your environment here, including the -version of Julia* - -**Steps to reproduce** - - 1. - 2. - 3. - -**Expected behavior** - -**Actual behavior** - -**Additional information** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml index 2afd0558..8765dd6e 100644 --- a/.github/workflows/CompatHelper.yml +++ b/.github/workflows/CompatHelper.yml @@ -37,8 +37,7 @@ jobs: - name: "Run CompatHelper" run: | import CompatHelper - CompatHelper.main(master_branch="develop") + CompatHelper.main() shell: julia --color=yes {0} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1268ea37..2b1acb88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - master + pull_request: jobs: test: @@ -13,29 +14,22 @@ jobs: fail-fast: false matrix: version: - - 'nightly' + - '1' # This is always the latest stable release in the 1.X series + - '1.10' # LTS + #- 'nightly' os: - ubuntu-latest - - macOS-latest + #- macOS-latest #- windows-latest arch: - x64 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v1 with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - - uses: actions/cache@v1 - env: - cache-name: cache-artifacts - with: - path: ~/.julia/artifacts - key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} - restore-keys: | - ${{ runner.os }}-test-${{ env.cache-name }}- - ${{ runner.os }}-test- - ${{ runner.os }}- + - uses: julia-actions/cache@v2 - uses: julia-actions/julia-buildpkg@latest - run: | git config --global user.name Tester @@ -43,6 +37,7 @@ jobs: - uses: julia-actions/julia-runtest@latest continue-on-error: ${{ matrix.version == 'nightly' }} - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v4 with: - file: lcov.info + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 33118fa5..20533c09 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,24 +1,40 @@ -name: Publish Docker image + +name: build docker image on: push: - tags: - - "v*" - release: - types: [published, created] + branches: + - master + tags: '*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: lcsb-biocore/gigasom.jl # lowercase of ${{ github.repository }} jobs: - push_to_registry: - name: Push Docker image to GitHub Packages + build: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - - name: Check out the repo - uses: actions/checkout@v2 - - name: Push to GitHub Registry - uses: mr-smithers-excellent/docker-build-push@v5 - with: - image: gigasom.jl - tags: latest - registry: ghcr.io - username: cylon-x - password: ${{ secrets.docker_token }} + - name: Checkout + uses: actions/checkout@v4 + - name: log into docker registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: build and push the docker container + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f8f181b3..e0cab802 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,26 +1,24 @@ + # ref: https://juliadocs.github.io/Documenter.jl/stable/man/hosting/#GitHub-Actions-1 name: Documentation on: + pull_request: push: branches: - - develop - master tags: '*' - release: - types: [published, created] jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@latest - with: - version: 1.7 + - uses: julia-actions/cache@v2 - name: Install dependencies - run: julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' + run: julia --color=yes --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy env: DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} # For authentication with SSH deploy key - run: julia --project=docs/ docs/make.jl + run: julia --color=yes --project=docs/ docs/make.jl diff --git a/.github/workflows/pr-format.yml b/.github/workflows/pr-format.yml new file mode 100644 index 00000000..3ff70d14 --- /dev/null +++ b/.github/workflows/pr-format.yml @@ -0,0 +1,62 @@ +on: + pull_request: + issue_comment: + types: [created] + +name: Formatting + +jobs: + formatting: + if: github.event_name == 'pull_request' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'OWNER' || github.event.issue.user.id == github.event.comment.user.id) && startsWith(github.event.comment.body, '/format') ) + runs-on: ubuntu-latest + steps: + - name: Clone the repository + uses: actions/checkout@v4 + - name: Checkout the pull request code # this checks out the actual branch so that one can commit into it + if: github.event_name == 'issue_comment' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr checkout ${{ github.event.issue.number }} + - name: Install JuliaFormatter and format + run: | + julia --color=yes -e 'import Pkg; Pkg.add("JuliaFormatter")' + julia --color=yes -e 'using JuliaFormatter; format(".")' + - name: Remove trailing whitespace + run: | + find -name '*.jl' -or -name '*.md' -or -name '*.toml' -or -name '*.yml' | while read filename ; do + # remove any trailing spaces + sed --in-place -e 's/\s*$//' "$filename" + # add a final newline if missing + if [[ -s "$filename" && $(tail -c 1 "$filename" |wc -l) -eq 0 ]] ; then + echo >> "$filename" + fi + # squash superfluous final newlines + sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' "$filename" + done + - name: Fail on formatting problems + if: github.event_name == 'pull_request' + run: | + if git diff --exit-code --quiet + then echo "Looks OK" + else echo "Formatting fixes required!"; git diff -p ; exit 1 + fi + - name: Commit fixes + if: github.event_name == 'issue_comment' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ `git status -s | wc -l` -ne 0 ] ; then + git config --local user.name "$GITHUB_ACTOR" + git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" + git commit -a -m "automatic formatting" -m "triggered by @$GITHUB_ACTOR on PR #${{ github.event.issue.number }}" + if git push + then gh pr comment ${{ github.event.issue.number }} --body \ + ":heavy_check_mark: Auto-formatting triggered by [this comment](${{ github.event.comment.html_url }}) succeeded, commited as `git rev-parse HEAD`" + else gh pr comment ${{ github.event.issue.number }} --body \ + ":x: Auto-formatting triggered by [this comment](${{ github.event.comment.html_url }}) failed, perhaps someone pushed to the PR in the meantime?" + fi + else + gh pr comment ${{ github.event.issue.number }} --body \ + ":sunny: Auto-formatting triggered by [this comment](${{ github.event.comment.html_url }}) succeeded, but the code was already formatted correctly." + fi From c9929e073aff9a668f841baf13332a9118b2e88c Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:51:04 +0200 Subject: [PATCH 02/61] stabilize --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 184b6e98..cf8e6f67 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "GigaSOM" uuid = "a03a9c34-069e-5582-a11c-5c984cab887c" -version = "0.7.1" +version = "1.0.0" [deps] CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" From 5858df541852cc0ba77580ecd7be22adf1e02c47 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:52:57 +0200 Subject: [PATCH 03/61] remove obsolete CIs --- .gitlab-ci.yml | 186 ------------------------------------------------- .travis.yml | 16 ----- gigasom.def | 22 ------ 3 files changed, 224 deletions(-) delete mode 100644 .gitlab-ci.yml delete mode 100644 .travis.yml delete mode 100644 gigasom.def diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index f4c4621e..00000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,186 +0,0 @@ - -stages: - - test - - assets - -variables: - GIT_STRATEGY: clone - DOCKER_DRIVER: overlay2 - DOCKER_TLS_CERTDIR: "" - APPTAINER_DOCKER_TAG: "v3.9.4" - DOCKER_HUB_TAG: "lcsbbiocore/gigasom.jl" - DOCKER_GHCR_TAG: "ghcr.io/lcsb-biocore/docker/gigasom.jl" - APPTAINER_GHCR_TAG: "lcsb-biocore/apptainer/gigasom.jl" - -# -# Predefined conditions for triggering jobs -# - -.global_trigger_pull_request: &global_trigger_pull_request - rules: - - if: $CI_COMMIT_BRANCH == "develop" - when: never - - if: $CI_COMMIT_BRANCH == "master" - when: never - - if: $CI_PIPELINE_SOURCE == "external_pull_request_event" - -.global_trigger_full_tests: &global_trigger_full_tests - rules: - - if: $CI_COMMIT_BRANCH == "develop" - - if: $CI_COMMIT_BRANCH == "master" - -.global_trigger_test_containers: &global_trigger_test_containers - rules: - - if: $CI_PIPELINE_SOURCE == "external_pull_request_event" - when: never - - if: $CI_COMMIT_BRANCH == "develop" - -.global_trigger_release_containers: &global_trigger_release_containers - rules: - - if: $CI_COMMIT_TAG =~ /^v/ - -# -# Test environment & platform settings -# - -.global_dind: &global_dind - image: docker:20.10.12 - tags: - - privileged - services: - - name: docker:20.10.12-dind - command: ["--tls=false", "--mtu=1458", "--registry-mirror", "https://docker-registry.lcsb.uni.lu"] - before_script: - - docker login -u $CI_USER_NAME -p $GITLAB_ACCESS_TOKEN $CI_REGISTRY - -.global_julia18: &global_julia18 - variables: - JULIA_VER: "v1.8.2" - -.global_env_linux: &global_env_linux - script: - - $ARTENOLIS_SOFT_PATH/julia/$JULIA_VER/bin/julia --inline=yes --check-bounds=yes --color=yes --project=@. -e 'import Pkg; Pkg.test(; coverage = true)' - -.global_env_win: &global_env_win - script: - - $global:LASTEXITCODE = 0 # Note the global prefix. - - Invoke-Expression $Env:ARTENOLIS_SOFT_PATH"\julia\"$Env:JULIA_VER"\bin\julia --inline=yes --check-bounds=yes --color=yes --project=@. -e 'import Pkg; Pkg.test(; coverage = true)'" - - exit $LASTEXITCODE - -.global_env_win10: &global_env_win10 - tags: - - windows10 - <<: *global_env_win - -.global_env_mac: &global_env_mac - tags: - - mac - script: - - $ARTENOLIS_SOFT_PATH/julia/$JULIA_VER/Contents/Resources/julia/bin/julia --inline=yes --check-bounds=yes --color=yes --project=@. -e 'import Pkg; Pkg.test(; coverage = true)' - -.global_build_apptainer: &global_build_apptainer - image: - name: "quay.io/singularity/singularity:$APPTAINER_DOCKER_TAG" - # the image entrypoint is the singularity binary by default - entrypoint: ["/bin/sh", "-c"] - tags: - - privileged - -# -# TESTS -# -# The "basic" required test that gets triggered for the basic testing, runs in -# any available docker and current julia -# - -docker:julia1.8: - stage: test - image: $CI_REGISTRY/r3/docker/julia-custom - script: - - julia --check-bounds=yes --inline=yes --project=@. -e "import Pkg; Pkg.test(; coverage = true)" - after_script: - - julia --project=test/coverage test/coverage/coverage-summary.jl - <<: *global_trigger_pull_request - -# -# The required compatibility test to pass on branches&tags before the docs get -# built & deployed -# - -linux:julia1.8: - stage: test - tags: - - slave01 - <<: *global_trigger_full_tests - <<: *global_julia18 - <<: *global_env_linux - -# -# Additional platform&environment compatibility tests -# - -windows10:julia1.8: - stage: test - <<: *global_trigger_full_tests - <<: *global_julia18 - <<: *global_env_win10 - -mac:julia1.8: - stage: test - <<: *global_trigger_full_tests - <<: *global_julia18 - <<: *global_env_mac - -# -# CONTAINERS -# - -apptainer-test: - stage: assets - script: | - alias apptainer=singularity - apptainer build gigasom-test.sif gigasom.def - <<: *global_build_apptainer - <<: *global_trigger_test_containers - -apptainer-release: - stage: assets - script: - - | - # build the container - alias apptainer=singularity - apptainer build gigasom.sif gigasom.def - - | - # push to GHCR - alias apptainer=singularity - export SINGULARITY_DOCKER_USERNAME="$GITHUB_ACCESS_USERNAME" - export SINGULARITY_DOCKER_PASSWORD="$GITHUB_ACCESS_TOKEN" - apptainer push gigasom.sif "oras://ghcr.io/$APPTAINER_GHCR_TAG:latest" - apptainer push gigasom.sif "oras://ghcr.io/$APPTAINER_GHCR_TAG:$CI_COMMIT_TAG" - <<: *global_build_apptainer - <<: *global_trigger_release_containers - -docker-test: - stage: assets - script: - - docker build -t "$DOCKER_HUB_TAG:testing" . - <<: *global_dind - <<: *global_trigger_test_containers - -docker-release: - stage: assets - script: - - docker build -t "$DOCKER_HUB_TAG:latest" . - # alias and push to docker hub - - docker tag "$DOCKER_HUB_TAG:latest" "$DOCKER_HUB_TAG:$CI_COMMIT_TAG" - - echo "$DOCKER_IO_ACCESS_TOKEN" | docker login --username "$DOCKER_IO_USER" --password-stdin - - docker push "$DOCKER_HUB_TAG:latest" - - docker push "$DOCKER_HUB_TAG:$CI_COMMIT_TAG" - # make 2 extra aliases and push to GHCR - - docker tag "$DOCKER_HUB_TAG:latest" "$DOCKER_GHCR_TAG:latest" - - docker tag "$DOCKER_HUB_TAG:latest" "$DOCKER_GHCR_TAG:$CI_COMMIT_TAG" - - echo "$GITHUB_ACCESS_TOKEN" | docker login ghcr.io --username "$GITHUB_ACCESS_USERNAME" --password-stdin - - docker push "$DOCKER_GHCR_TAG:latest" - - docker push "$DOCKER_GHCR_TAG:$CI_COMMIT_TAG" - <<: *global_dind - <<: *global_trigger_release_containers diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7b2ea243..00000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: julia - -branches: - only: - - develop - - master - -os: - - linux - -julia: - - 1.0 - - 1.5 - -notifications: - email: false diff --git a/gigasom.def b/gigasom.def deleted file mode 100644 index b67ee0ca..00000000 --- a/gigasom.def +++ /dev/null @@ -1,22 +0,0 @@ -Bootstrap: library -From: cylon-x/lcsb-biocore/julia-base:latest - -%setup - export GIT_WORK_TREE="${SINGULARITY_ROOTFS}/project/" - export GIT_DIR="`pwd`/.git" - mkdir -p "${GIT_WORK_TREE}" - git checkout -f - -%post - export JULIA_DEPOT_PATH=/user/.julia - export PATH=/opt/julia/bin:$PATH - - cd project - julia --project -e 'import Pkg; Pkg.instantiate(); Pkg.build(); Pkg.precompile();' - - echo 'using GigaSOM' > /project/.startup.jl - - chmod -R a+rX $JULIA_DEPOT_PATH - -%runscript - julia --banner=no --project=/project --compile=min -i /project/.startup.jl From b22dd85e7f5f0164ddbf866ba17e4b06e0c29641 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:53:29 +0200 Subject: [PATCH 04/61] default the ccov config --- codecov.yml | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index cbfea863..00000000 --- a/codecov.yml +++ /dev/null @@ -1,6 +0,0 @@ -coverage: - status: - project: - default: - threshold: 15 - patch: off From bc63501a4f77b3bdf145113a08d678e1d6fa5201 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:55:05 +0200 Subject: [PATCH 05/61] juliaformatter --- Project.toml | 2 ++ docs/make.jl | 47 +++++++++++++++++++++++-------------------- test/testLoadPBMC8.jl | 4 ++-- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/Project.toml b/Project.toml index cf8e6f67..60e871fa 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,7 @@ Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" DistributedArrays = "aaf54ef3-cdf8-58ed-94cc-d582ad619b94" DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" +DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" FCSFiles = "d76558cf-badf-52d4-a17e-381ab0b0d937" FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" NearestNeighbors = "b8a86587-4115-5ab1-83bc-aa920d37bbce" @@ -24,6 +25,7 @@ Distances = "^0.8.2, 0.9, 0.10" DistributedArrays = "^0.6.4" DistributedData = "0.1.4, 0.2" Distributions = "^0.21.1, 0.22, 0.23, 0.24, 0.25" +DocStringExtensions = "0.9.5" FCSFiles = "^0.1.1, 0.2" FileIO = "^1.0.7" JSON = "0.21" diff --git a/docs/make.jl b/docs/make.jl index 3949c708..f74273fe 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,31 +1,34 @@ using Documenter, GigaSOM -makedocs(modules = [GigaSOM], - clean = false, - format = Documenter.HTML(prettyurls = !("local" in ARGS), - canonical = "https://lcsb-biocore.github.io/GigaSOM.jl/stable/", - assets = ["assets/gigasomlogotransp.ico"]), - sitename = "GigaSOM.jl", - authors = "The developers of GigaSOM.jl", - linkcheck = !("skiplinks" in ARGS), - pages = [ - "Home" => "index.md", - "Background" => "background.md", - "Tutorial" => [ - "Introduction" => "tutorials/basicUsage.md", - "Cytometry data" => "tutorials/processingFCSData.md", - "Advanced distributed processing" => "tutorials/distributedProcessing.md", - "Conclusion" => "tutorials/whereToGoNext.md", - ], - "Functions" => "functions.md", - "How to contribute" => "howToContribute.md", - ], - ) +makedocs( + modules = [GigaSOM], + clean = false, + format = Documenter.HTML( + prettyurls = !("local" in ARGS), + canonical = "https://lcsb-biocore.github.io/GigaSOM.jl/stable/", + assets = ["assets/gigasomlogotransp.ico"], + ), + sitename = "GigaSOM.jl", + authors = "The developers of GigaSOM.jl", + linkcheck = !("skiplinks" in ARGS), + pages = [ + "Home" => "index.md", + "Background" => "background.md", + "Tutorial" => [ + "Introduction" => "tutorials/basicUsage.md", + "Cytometry data" => "tutorials/processingFCSData.md", + "Advanced distributed processing" => "tutorials/distributedProcessing.md", + "Conclusion" => "tutorials/whereToGoNext.md", + ], + "Functions" => "functions.md", + "How to contribute" => "howToContribute.md", + ], +) deploydocs( repo = "github.com/LCSB-BioCore/GigaSOM.jl.git", target = "build", branch = "gh-pages", devbranch = "develop", - push_preview = true + push_preview = true, ) diff --git a/test/testLoadPBMC8.jl b/test/testLoadPBMC8.jl index e1328b6a..c43d2f5c 100644 --- a/test/testLoadPBMC8.jl +++ b/test/testLoadPBMC8.jl @@ -42,7 +42,7 @@ end fileNames = readdir() csDict = Dict{String,Any}() for f in fileNames - if f[end-3:end] == ".fcs" || f[end-4:end] == ".xlsx" + if f[(end-3):end] == ".fcs" || f[(end-4):end] == ".xlsx" cs = bytes2hex(sha256(f)) csDict[f] = cs end @@ -56,7 +56,7 @@ end md = DataFrame(XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)) panel = DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)) -antigens = panel[panel[:, :Lineage].==1, :Antigen] +antigens = panel[panel[:, :Lineage] .== 1, :Antigen] _, fcsParams = loadFCSHeader(md[1, :file_name]) _, fcsAntigens = getMarkerNames(getMetaData(fcsParams)) cleanNames!(antigens) From 7d0f0f75f600a894085540770ddec51ccbf6a979 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:55:40 +0200 Subject: [PATCH 06/61] smash space errors --- .github/CONTRIBUTING.md | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 1 - docs/src/index.md | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 46342d61..a096b49e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -31,4 +31,4 @@ Please follow these steps to have your contribution considered by the maintainer 3. After you submit your pull request, verify that all status checks are passing After you submitted a pull request, a label might be assigned that allows us -to track and manage issues and pull requests. \ No newline at end of file +to track and manage issues and pull requests. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d91a23a5..9c81fa6d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,3 @@ # Description of the proposed change *Please include a short description of enhancement here* - diff --git a/docs/src/index.md b/docs/src/index.md index f8d4390c..b830d185 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -64,4 +64,4 @@ If you want to contribute, please read these guidelines first: ```@contents Pages = ["howToContribute.md"] -``` \ No newline at end of file +``` From b5b3afe92acf22c2e83d60ab93f3e9d98f89138a Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 10:56:20 +0200 Subject: [PATCH 07/61] remove stuff that should be obvious nowadays --- .github/CODE_OF_CONDUCT.md | 76 -------------------------------- .github/CONTRIBUTING.md | 34 -------------- .github/PULL_REQUEST_TEMPLATE.md | 3 -- 3 files changed, 113 deletions(-) delete mode 100644 .github/CODE_OF_CONDUCT.md delete mode 100644 .github/CONTRIBUTING.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 215b057c..00000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,76 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at lcsb-r3 .at. uni.lu. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index a096b49e..00000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,34 +0,0 @@ -# Contributing to GigaSOM - -:+1::tada: Thanks for taking the time to contribute to [GigaSOM.jl](https://github.com/LCSB-BioCore/GigaSOM.jl)! :tada::+1: - -## How can I contribute? - -A complete technical guide on how to get started contributing is given [here](https://lcsb-biocore.github.io/GigaSOM.jl/stable/howToContribute/). - -## How to report a bug or suggest an enhancement - -Please use the [GitHub issue tracker](https://github.com/LCSB-BioCore/GigaSOM.jl/issues) to report any problems with the software, and discuss any potential questions about GigaSOM use. - -Before creating bug reports, please check [the open -issues](https://github.com/LCSB-BioCore/GigaSOM.jl/issues) as you might find -out that you don't need to create one. When you are creating a bug report, -please include as many details as possible. Fill out the required template, the -information it asks for helps us resolve issues faster. - -> If you find a Closed issue that seems like it is the same thing that - you're experiencing, open a new issue and include a link to the original issue - in the body of your new one. - -If reporting issues, please do not forget to include a description of conditions under which the issue occurs; preferably a code snippet that can be run separately (sometimes termed "minimal crashing example"). - -## How to submit a pull request (PR)? - -Please follow these steps to have your contribution considered by the maintainers: - -1. Follow all instructions in the template -2. Submit your pull request against the `develop` branch -3. After you submit your pull request, verify that all status checks are passing - -After you submitted a pull request, a label might be assigned that allows us -to track and manage issues and pull requests. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 9c81fa6d..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Description of the proposed change - -*Please include a short description of enhancement here* From b23f0d1fbe1ec6e5a2f8756c626f0b54a2b58c69 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 11:12:36 +0200 Subject: [PATCH 08/61] clean up file structure a little --- Project.toml | 2 -- src/GigaSOM.jl | 25 ++++++++++++------------ src/base/dataops.jl | 8 -------- src/{io/process.jl => data_utils.jl} | 9 +++++++++ src/{analysis => }/embedding.jl | 0 src/{io => }/input.jl | 0 src/{analysis/core.jl => som.jl} | 0 src/{base/trainutils.jl => som_utils.jl} | 0 src/{io/splitting.jl => split.jl} | 0 src/{base => }/structs.jl | 0 10 files changed, 22 insertions(+), 22 deletions(-) delete mode 100644 src/base/dataops.jl rename src/{io/process.jl => data_utils.jl} (93%) rename src/{analysis => }/embedding.jl (100%) rename src/{io => }/input.jl (100%) rename src/{analysis/core.jl => som.jl} (100%) rename src/{base/trainutils.jl => som_utils.jl} (100%) rename src/{io/splitting.jl => split.jl} (100%) rename src/{base => }/structs.jl (100%) diff --git a/Project.toml b/Project.toml index 60e871fa..affbfd5e 100644 --- a/Project.toml +++ b/Project.toml @@ -7,7 +7,6 @@ CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Distances = "b4f34e82-e78d-54a5-968a-f98e89d6e8f7" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" -DistributedArrays = "aaf54ef3-cdf8-58ed-94cc-d582ad619b94" DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" @@ -22,7 +21,6 @@ StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" -DistributedArrays = "^0.6.4" DistributedData = "0.1.4, 0.2" Distributions = "^0.21.1, 0.22, 0.23, 0.24, 0.25" DocStringExtensions = "0.9.5" diff --git a/src/GigaSOM.jl b/src/GigaSOM.jl index ad9466fe..d2f5083c 100644 --- a/src/GigaSOM.jl +++ b/src/GigaSOM.jl @@ -1,9 +1,10 @@ """ -Main module for `GigaSOM.jl` - Huge-scale, high-performance flow cytometry clustering + module GigaSOM -The documentation is here: http://LCSB-BioCore.github.io/GigaSOM.jl -""" +Huge-scale, high-performance flow cytometry data clustering. +See documentation at: +""" module GigaSOM using CSV @@ -14,22 +15,22 @@ using DistributedData using Distributions using FCSFiles using FileIO -using DistributedArrays using NearestNeighbors using Serialization using StableRNGs -include("base/structs.jl") +include("structs.jl") + +include("som.jl") +include("som_utils.jl") +include("embedding.jl") -include("base/dataops.jl") -include("base/trainutils.jl") +include("load.jl") +include("split.jl") -include("analysis/core.jl") -include("analysis/embedding.jl") +include("utils.jl") +include("data_utils.jl") -include("io/input.jl") -include("io/process.jl") -include("io/splitting.jl") #core export initGigaSOM, trainGigaSOM, mapToGigaSOM diff --git a/src/base/dataops.jl b/src/base/dataops.jl deleted file mode 100644 index e9d8fee3..00000000 --- a/src/base/dataops.jl +++ /dev/null @@ -1,8 +0,0 @@ -""" - dtransform_asinh(dInfo::Dinfo, columns::Vector{Int}, cofactor=5) - -Transform columns of the dataset by asinh transformation with `cofactor`. -""" -function dtransform_asinh(dInfo::Dinfo, columns::Vector{Int}, cofactor = 5) - dapply_cols(dInfo, (v, _) -> asinh.(v ./ cofactor), columns) -end diff --git a/src/io/process.jl b/src/data_utils.jl similarity index 93% rename from src/io/process.jl rename to src/data_utils.jl index 52a71eb5..38419481 100644 --- a/src/io/process.jl +++ b/src/data_utils.jl @@ -146,3 +146,12 @@ function getSpillover( end return nothing end + +""" + dtransform_asinh(dInfo::Dinfo, columns::Vector{Int}, cofactor=5) + +Transform columns of the dataset by asinh transformation with `cofactor`. +""" +function dtransform_asinh(dInfo::Dinfo, columns::Vector{Int}, cofactor = 5) + dapply_cols(dInfo, (v, _) -> asinh.(v ./ cofactor), columns) +end diff --git a/src/analysis/embedding.jl b/src/embedding.jl similarity index 100% rename from src/analysis/embedding.jl rename to src/embedding.jl diff --git a/src/io/input.jl b/src/input.jl similarity index 100% rename from src/io/input.jl rename to src/input.jl diff --git a/src/analysis/core.jl b/src/som.jl similarity index 100% rename from src/analysis/core.jl rename to src/som.jl diff --git a/src/base/trainutils.jl b/src/som_utils.jl similarity index 100% rename from src/base/trainutils.jl rename to src/som_utils.jl diff --git a/src/io/splitting.jl b/src/split.jl similarity index 100% rename from src/io/splitting.jl rename to src/split.jl diff --git a/src/base/structs.jl b/src/structs.jl similarity index 100% rename from src/base/structs.jl rename to src/structs.jl From 967513e6e878a8c38715d4c37a8883f04eb7b7d6 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 11:48:19 +0200 Subject: [PATCH 09/61] convert doc headers to DSExts --- src/data_utils.jl | 15 +++++++------- src/embedding.jl | 27 +++--------------------- src/{input.jl => load.jl} | 30 +++++++++++---------------- src/som.jl | 43 ++++++++++----------------------------- src/som_utils.jl | 13 ++++++------ src/split.jl | 19 +++-------------- src/structs.jl | 10 ++------- 7 files changed, 46 insertions(+), 111 deletions(-) rename src/{input.jl => load.jl} (88%) diff --git a/src/data_utils.jl b/src/data_utils.jl index 38419481..7d6c9f9b 100644 --- a/src/data_utils.jl +++ b/src/data_utils.jl @@ -1,5 +1,5 @@ """ - cleanNames!(mydata::Vector{String}) +$(TYPEDSIGNATURES) Replaces problematic characters in column names, avoids duplicate names, and prefixes an '_' if the name starts with a number. @@ -30,7 +30,8 @@ function cleanNames!(mydata::Vector{String}) end """ - getMetaData(f) +$(TYPEDSIGNATURES) + Collect the meta data information in a more user friendly format. # Arguments: @@ -78,7 +79,7 @@ function getMetaData(meta::Dict{String,String})::DataFrame end """ - getMarkerNames(meta::DataFrame)::Tuple{Vector{String}, Vector{String}} +$(TYPEDSIGNATURES) Extract suitable raw names (useful for selecting columns) and pretty readable names (useful for humans) from FCS file metadata. @@ -99,7 +100,7 @@ end """ - compensate!(data::Matrix{Float64}, spillover::Matrix{Float64}, cols::Vector{Int}) +$(TYPEDSIGNATURES) Apply a compensation matrix in `spillover` (the individual columns of which describe, in order, the spillover of `cols` in `data`) to the matrix `data` @@ -110,7 +111,7 @@ function compensate!(data::Matrix{Float64}, spillover::Matrix{Float64}, cols::Ve end """ - parseSpillover(str::String)::Union{Tuple{Vector{String},Matrix{Float64}}, Nothing} +$(TYPEDSIGNATURES) Parses the spillover matrix from the string from FCS parameter value. """ @@ -129,7 +130,7 @@ function parseSpillover(str::String)::Tuple{Vector{String},Matrix{Float64}} end """ - getSpillover(params::Dict{String, String})::Union{Tuple{Vector{String},Matrix{Float64}}, Nothing} +$(TYPEDSIGNATURES) Get a spillover matrix from FCS `params`. Returns a pair with description of columns to be applied, and with the actual spillover matrix. Returns `nothing` @@ -148,7 +149,7 @@ function getSpillover( end """ - dtransform_asinh(dInfo::Dinfo, columns::Vector{Int}, cofactor=5) +$(TYPEDSIGNATURES) Transform columns of the dataset by asinh transformation with `cofactor`. """ diff --git a/src/embedding.jl b/src/embedding.jl index 28d35e2e..d5fe293f 100644 --- a/src/embedding.jl +++ b/src/embedding.jl @@ -1,13 +1,5 @@ """ - embedGigaSOM(som::GigaSOM.Som, - dInfo::Dinfo; - knnTreeFun = BruteTree, - metric = Euclidean(), - k::Int64=0, - adjust::Float64=1.0, - smooth::Float64=0.0, - m::Float64=10.0, - output::Symbol=tmp_symbol(dInfo))::Dinfo +$(TYPEDSIGNATURES) Return a data frame with X,Y coordinates of EmbedSOM projection of the data. @@ -66,14 +58,7 @@ function embedGigaSOM( end """ - embedGigaSOM(som::GigaSOM.Som, - data; - knnTreeFun = BruteTree, - metric = Euclidean(), - k::Int64=0, - adjust::Float64=1.0, - smooth::Float64=0.0, - m::Float64=10.0) +$(TYPEDSIGNATURES) Overload of `embedGigaSOM` for simple DataFrames and matrices. This slices the data using `DistributedArrays`, sends them the workers, and runs normal @@ -125,13 +110,7 @@ function embedGigaSOM( end """ - embedGigaSOM_internal(som::GigaSOM.Som, - data::Matrix{Float64}, - tree, - k::Int64, - adjust::Float64, - boost::Float64, - m::Float64) +$(TYPEDSIGNATURES) Internal function to compute parts of the embedding on a prepared kNN-tree structure (`tree`) and `smooth` converted to `boost`. diff --git a/src/input.jl b/src/load.jl similarity index 88% rename from src/input.jl rename to src/load.jl index 7e8901a0..93942f85 100644 --- a/src/input.jl +++ b/src/load.jl @@ -1,5 +1,5 @@ """ - loadFCSHeader(fn::String)::Tuple{Vector{Int}, Dict{String,String}} +$(TYPEDSIGNATURES) Efficiently extract data offsets and keyword dictionary from an FCS file. """ @@ -13,7 +13,7 @@ function loadFCSHeader(fn::String)::Tuple{Vector{Int},Dict{String,String}} end """ - getFCSSize(offsets, params)::Tuple{Int,Int} +$(TYPEDSIGNATURES) Convert the offsets and keywords from an FCS file to cell and parameter count, respectively. @@ -46,7 +46,7 @@ function getFCSSize(offsets, params)::Tuple{Int,Int} end """ - loadFCSSizes(fns::Vector{String}) +$(TYPEDSIGNATURES) Load cell counts in many FCS files at once. Useful as input for `slicesof`. """ @@ -60,7 +60,7 @@ function loadFCSSizes(fns::Vector{String})::Vector{Int} end """ - loadFCS(fn::String; applyCompensation::Bool=true)::Tuple{Dict{String,String}, Matrix{Float64}} +$(TYPEDSIGNATURES) Read a FCS file. Return a tuple that contains in order: @@ -96,7 +96,7 @@ function loadFCS( end """ - loadFCSSet(name::Symbol, fns::Vector{String}, pids=workers(); applyCompensation=true, postLoad=(d,i)->d)::Dinfo +$(TYPEDSIGNATURES) This runs the FCS loading machinery in a distributed way, so that the files `fns` (with full path) are sliced into equal parts and saved as a distributed @@ -148,7 +148,7 @@ function loadFCSSet( end """ - selectFCSColumns(selectColnames::Vector{String}) +$(TYPEDSIGNATURES) Return a function useful with `loadFCSSet`, which loads only the specified (prettified) column names from the FCS files. Use `getMetaData`, @@ -169,7 +169,7 @@ function selectFCSColumns(selectColnames::Vector{String}) end """ - distributeFCSFileVector(name::Symbol, fns::Vector{String}, pids=workers())::Dinfo +$(TYPEDSIGNATURES) Distribute a vector of integers among the workers that describes which file from `fns` the cell comes from. Useful for producing per-file statistics. The @@ -183,7 +183,7 @@ function distributeFCSFileVector(name::Symbol, fns::Vector{String}, pids = worke end """ - distributeFileVector(name::Symbol, sizes::Vector{Int}, slices::Vector{Tuple{Int,Int,Int,Int}}, pids=workers())::Dinfo +$(TYPEDSIGNATURES) Generalized version of `distributeFCSFileVector` that produces the integer vector from any `sizes` and `slices`. @@ -204,7 +204,7 @@ function distributeFileVector( end """ - function getCSVSize(fn::String; args...)::Tuple{Int,Int} +$(TYPEDSIGNATURES) Read the dimensions (number of rows and columns, respectively) from a CSV file `fn`. `args` are passed to function `CSV.file`. @@ -227,7 +227,7 @@ function getCSVSize(fn::String; args...)::Tuple{Int,Int} end """ - function loadCSVSizes(fns::Vector{String}; args...)::Vector{Int} +$(TYPEDSIGNATURES) Determine number of rows in a list of CSV files (passed as `fns`). Equivalent to `loadFCSSizes`. @@ -237,7 +237,7 @@ function loadCSVSizes(fns::Vector{String}; args...)::Vector{Int} end """ - function loadCSV(fn::String; args...)::Matrix{Float64} +$(TYPEDSIGNATURES) CSV equivalent of `loadFCS`. The metadata (header, column names) are not extracted. `args` are passed to `CSV.read`. @@ -247,13 +247,7 @@ function loadCSV(fn::String; args...)::Matrix{Float64} end """ - function loadCSVSet( - name::Symbol, - fns::Vector{String}, - pids = workers(); - postLoad = (d, i) -> d, - csvargs..., - )::Dinfo +$(TYPEDSIGNATURES) CSV equivalent of `loadFCSSet`. `csvargs` are passed as keyword arguments to CSV-loading functions. diff --git a/src/som.jl b/src/som.jl index 83f40cda..d2cc6681 100644 --- a/src/som.jl +++ b/src/som.jl @@ -1,5 +1,5 @@ """ - initGigaSOM(data, args...) +$(TYPEDSIGNATURES) Initializes a SOM by random selection from the training data. A generic overload that works for matrices and DataFrames that can be coerced to @@ -21,9 +21,7 @@ function initGigaSOM(data::Union{Matrix,DataFrame}, args...; kwargs...) end """ - function initGigaSOM(data::Dinfo, - xdim::Int64, ydim::Int64 = xdim; - seed=rand(Int), rng=StableRNG(seed)) +$(TYPEDSIGNATURES) `initGigaSOM` overload for working with distributed-style `Dinfo` data. The rest of the arguments is passed to the data-independent @@ -40,10 +38,7 @@ function initGigaSOM(data::Dinfo, args...; kwargs...) end """ - function initGigaSOM(ncol::Int64, - means::Vector{Float64}, sdevs::Vector{Float64}, - xdim::Int64, ydim::Int64 = xdim; - seed = rand(Int), rng = StableRNG(seed)) +$(TYPEDSIGNATURES) Generate a stable random initial SOM with the random distribution that matches the parameters. @@ -82,19 +77,7 @@ end """ - trainGigaSOM( - som::Som, - dInfo::Dinfo; - kernelFun::Function = gaussianKernel, - metric = Euclidean(), - somDistFun = distMatrix(Chebyshev()), - knnTreeFun = BruteTree, - rStart = 0.0, - rFinal = 0.1, - radiusFun = expRadius(-5.0), - epochs = 20, - eachEpoch = (e, r, som) -> nothing, - ) +$(TYPEDSIGNATURES) # Arguments: - `som`: object of type Som with an initialised som @@ -166,8 +149,7 @@ function trainGigaSOM( end """ - trainGigaSOM(som::Som, train; - kwargs...) +$(TYPEDSIGNATURES) Overload of `trainGigaSOM` for simple DataFrames and matrices. This slices the data, distributes them to the workers, and runs normal `trainGigaSOM`. Data is @@ -186,7 +168,7 @@ end """ - doEpoch(x::Array{Float64, 2}, codes::Array{Float64, 2}, tree) +$(TYPEDSIGNATURES) vectors and the adjustment in radius after each epoch. @@ -215,7 +197,7 @@ function doEpoch(x::Array{Float64,2}, codes::Array{Float64,2}, tree) end """ - distributedEpoch(dInfo::Dinfo, codes::Matrix{Float64}, tree) +$(TYPEDSIGNATURES) Execute the `doEpoch` in parallel on workers described by `dInfo` and collect the results. Returns pair of numerator and denominator matrices. @@ -230,9 +212,7 @@ end """ - mapToGigaSOM(som::Som, dInfo::Dinfo; - knnTreeFun = BruteTree, metric = Euclidean(), - output::Symbol=tmp_symbol(dInfo)::Dinfo +$(TYPEDSIGNATURES) Compute the index of the BMU for each row of the input data. @@ -260,9 +240,8 @@ function mapToGigaSOM( end """ - mapToGigaSOM(som::Som, data; - knnTreeFun = BruteTree, - metric = Euclidean()) +$(TYPEDSIGNATURES) + Overload of `mapToGigaSOM` for simple DataFrames and matrices. This slices the data using `DistributedArrays`, sends them the workers, and runs normal `mapToGigaSOM`. Data is `unscatter`d after the computation. @@ -285,7 +264,7 @@ function mapToGigaSOM(som::Som, data; knnTreeFun = BruteTree, metric = Euclidean end """ - scaleEpochTime(iteration::Int64, epochs::Int64) +$(TYPEDSIGNATURES) Convert iteration ID and epoch number to relative time in training. """ diff --git a/src/som_utils.jl b/src/som_utils.jl index 589d7ecf..f409ad5c 100644 --- a/src/som_utils.jl +++ b/src/som_utils.jl @@ -1,6 +1,6 @@ """ - linearRadius(initRadius::Float64, iteration::Int64, decay::String, epochs::Int64) +$(TYPEDSIGNATURES) Return a neighbourhood radius. Use as the `radiusFun` parameter for `trainGigaSOM`. @@ -23,7 +23,7 @@ end """ - expRadius(steepness::Float64) +$(TYPEDSIGNATURES) Return a function to be used as a `radiusFun` of `trainGigaSOM`, which causes exponencial decay with the selected steepness. @@ -64,7 +64,7 @@ end """ - gridRectangular(xdim, ydim) +$(TYPEDSIGNATURES) Create coordinates of all neurons on a rectangular SOM. @@ -93,7 +93,7 @@ end """ - gaussianKernel(x, r::Float64) +$(TYPEDSIGNATURES) Return the value of normal distribution PDF (σ=`r`, μ=0) at `x` """ @@ -112,7 +112,7 @@ end """ - bubbleKernel(x, r::Float64) +$(TYPEDSIGNATURES) Return a "bubble" (spherical) distribution kernel. @@ -122,6 +122,7 @@ function bubbleKernel(x, r::Float64) end """ +$(TYPEDSIGNATURES) thresholdKernel(x, r::Float64) Simple FlowSOM-like hard-threshold kernel @@ -134,7 +135,7 @@ function thresholdKernel(x, r::Float64, maxRatio = 4 / 5, zero = 1e-6) end """ - distMatrix(metric=Chebyshev()) +$(TYPEDSIGNATURES) Return a function that uses the `metric` (compatible with metrics from package `Distances`) calculates distance matrixes from normal row-wise data matrices, using the `metric`. diff --git a/src/split.jl b/src/split.jl index 98dbdc22..56c31dff 100644 --- a/src/split.jl +++ b/src/split.jl @@ -1,5 +1,5 @@ """ - slicesof(lengths::Vector{Int}, slices::Int)::Vector{Tuple{Int,Int,Int,Int}} +$(TYPEDSIGNATURES) Given a list of `lengths` of input arrays, compute a slicing into a specified amount of equally-sized `slices`. @@ -46,7 +46,7 @@ function slicesof(lengths::Vector{Int}, slices::Int)::Vector{Tuple{Int,Int,Int,I end """ - vcollectSlice(loadMtx, (startFile, startOff, finalFile, finalOff)::Tuple{Int,Int,Int,Int})::Matrix +$(TYPEDSIGNATURES) Given a method to obtain matrix content (`loadMtx`), reconstruct a slice from the information generated by `slicesof`. @@ -61,19 +61,6 @@ index of one FCS file), and is expected to return that input part as a whole matrix. `vcollectSlice` correctly calls this function as required and extracts relevant portions of the matrices, so that at the end the whole slice can be pasted together. - -Example: - - # get a list of files - filenames=["a.fcs", "b.fcs"] - # get descriptions of 5 equally sized parts of the data - slices = slicesof(loadFCSSizes(filenames), 5) - - # reconstruct first 3 columns of the first slice - mySlice = vcollectSlice( - i -> last(loadFCS(slices[i]))[:,1:3], - slices[1]) - # (note: function loadFCS returns 4 items, the matrix is the last one) """ function vcollectSlice( loadMtx, @@ -92,7 +79,7 @@ function vcollectSlice( end """ - collectSlice(loadVec, (startFile, startOff, finalFile, finalOff)::Tuple{Int,Int,Int,Int})::Vector +$(TYPEDSIGNATURES) Alternative of `vcollectSlice` for 1D vectors. """ diff --git a/src/structs.jl b/src/structs.jl index b9689698..35c57086 100644 --- a/src/structs.jl +++ b/src/structs.jl @@ -1,16 +1,10 @@ """ - Som +$(TYPEDEF) Structure to hold all data of a trained SOM. # Fields: -- `codes::Array{Float64,2}`: 2D-array of codebook vectors. One vector per row -- `xdim::Int`: number of neurons in x-direction -- `ydim::Int`: number of neurons in y-direction -- `numCodes::Int`: total number of neurons -- `grid::Array{Float64,2}`: 2D-array of coordinates of neurons on the map - (2 columns (x,y)] for rectangular and hexagonal maps - 3 columns (x,y,z) for spherical maps) +$(TYPEDFIELDS) """ mutable struct Som codes::Matrix{Float64} From dbdfdef8175f54cea4b7b06bde6919e2a7472544 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 12:29:47 +0200 Subject: [PATCH 10/61] use DSEs --- src/GigaSOM.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/GigaSOM.jl b/src/GigaSOM.jl index d2f5083c..90f0d002 100644 --- a/src/GigaSOM.jl +++ b/src/GigaSOM.jl @@ -7,6 +7,8 @@ See documentation at: """ module GigaSOM +using DocStringExtensions + using CSV using DataFrames using Distances From 83a403aa64c89ab2c5a0f685ef517af8f38d2f38 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 12:34:22 +0200 Subject: [PATCH 11/61] utils gone --- src/GigaSOM.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/GigaSOM.jl b/src/GigaSOM.jl index 90f0d002..bf535483 100644 --- a/src/GigaSOM.jl +++ b/src/GigaSOM.jl @@ -29,8 +29,6 @@ include("embedding.jl") include("load.jl") include("split.jl") - -include("utils.jl") include("data_utils.jl") From a2295e1f9f0a0b006c8db2403a8f1d6788e14942 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 12:47:19 +0200 Subject: [PATCH 12/61] some docs cleaning --- docs/make.jl | 12 +-- docs/src/howToContribute.md | 125 ------------------------ docs/src/index.md | 68 +------------ docs/src/{functions.md => reference.md} | 6 +- src/GigaSOM.jl | 21 +++- src/{embedding.jl => embed.jl} | 0 test/coverage/coverage-summary.jl | 12 --- 7 files changed, 29 insertions(+), 215 deletions(-) delete mode 100644 docs/src/howToContribute.md rename docs/src/{functions.md => reference.md} (68%) rename src/{embedding.jl => embed.jl} (100%) delete mode 100644 test/coverage/coverage-summary.jl diff --git a/docs/make.jl b/docs/make.jl index f74273fe..b2c3bf88 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -2,15 +2,13 @@ using Documenter, GigaSOM makedocs( modules = [GigaSOM], - clean = false, format = Documenter.HTML( - prettyurls = !("local" in ARGS), + ansicolor = true, canonical = "https://lcsb-biocore.github.io/GigaSOM.jl/stable/", assets = ["assets/gigasomlogotransp.ico"], ), sitename = "GigaSOM.jl", - authors = "The developers of GigaSOM.jl", - linkcheck = !("skiplinks" in ARGS), + linkcheck = false, pages = [ "Home" => "index.md", "Background" => "background.md", @@ -20,8 +18,7 @@ makedocs( "Advanced distributed processing" => "tutorials/distributedProcessing.md", "Conclusion" => "tutorials/whereToGoNext.md", ], - "Functions" => "functions.md", - "How to contribute" => "howToContribute.md", + "Reference" => "reference.md", ], ) @@ -29,6 +26,5 @@ deploydocs( repo = "github.com/LCSB-BioCore/GigaSOM.jl.git", target = "build", branch = "gh-pages", - devbranch = "develop", - push_preview = true, + devbranch = "master", ) diff --git a/docs/src/howToContribute.md b/docs/src/howToContribute.md deleted file mode 100644 index 3ee843e0..00000000 --- a/docs/src/howToContribute.md +++ /dev/null @@ -1,125 +0,0 @@ -# How to contribute to/develop GigaSOM - -If you want to contribute to the `GigaSOM` package, please fork the present -repository by following [these instructions](https://help.github.com/en/github/getting-started-with-github/fork-a-repo). - -## Step 1: Retrieve a local version of GigaSOM - -There are two ways that you can retrieve a local copy of the package: one is to -manually clone the forked repository, and the second one is to use the -intergrated Julia package manager. - -### Option 1: Manually clone your fork - -:warning: Please make sure you have _forked_ the repository, as described above. - -You can do this as follows from the command line: - -```bash -$ git clone git@github.com:yourUsername/GigaSOM.jl.git GigaSOM.jl -$ cd GigaSOM.jl -$ git checkout -b yourNewBranch origin/develop -``` - -where `yourUsername` is your Github username and `yourNewBranch` is the name of a new branch. - -Then, in order to develop the package, you can install your cloned version as -follows (make sure you are in the `GigaSOM.jl` directory): - -```julia -(v1.1) pkg> add . -``` - -(press `]` to get into the packaging environment) - -This adds the `GigaSOM.jl` package and all its dependencies. You can verify -that the installation worked by typing: - -```julia -(v1.1) pkg> status -``` - -If everything went smoothly, this should print something similar to: - -```julia -(v1.1) pkg> status - Status `~/.julia/environments/v1.1/Project.toml` - [a03a9c34] GigaSOM v0.0.5 #yourNewBranch (.) -``` - -Now, you can readily start using the `GigaSOM` module: - -```julia -julia> using GigaSOM -``` - -### Option 2: Use the Julia package manager - -When you are used to using the Julia package manager for developing or -contributing to packages, you can type: - -```julia -(v1.1) pkg> dev GigaSOM -``` - -This will install the `GigaSOM` package locally and check it out for -development. You can check the location of the package with: - -```julia -(v1.1) pkg> status - Status `~/.julia/environments/v1.1/Project.toml` - [a03a9c34] GigaSOM v0.0.5 [`~/.julia/dev/GigaSOM`] -``` - -The default location of the package is `~/.julia/dev/GigaSOM`. - -You can then set your remote by executing these commands in a regular shell: - -```bash -$ cd ~/.julia/dev/GigaSOM -$ git remote rename origin upstream # renames the origin as upstream -$ git remote add origin git@github.com:yourUsername/GigaSOM.jl.git -$ git fetch origin -``` - -where `yourUsername` is your Github username. - -:warning: Make sure that your fork exists under `github.com/yourUsername/GigaSOM.jl`. - -Then, checkout a branch `yourNewBranch`: - -```bash -$ cd ~/.julia/dev/GigaSOM -$ git checkout -b yourNewBranch origin/develop -``` - -Then, you can readily use the `GigaSOM` package: - -```julia -julia> using GigaSOM -``` - -After making changes, precompile the package: - -```julia -(v1.1) pkg> precompile -``` - -## Step 2: Activate GigaSOM - -:warning: Please note that you cannot use the dependencies of GigaSOM directly, -unless they are installed separately or the environment has been activated: - -```julia -(v1.1) pkg> activate . -(GigaSOM) pkg> instantiate -``` - -Now, the environment is activated (you can see it with the prompt change -`(GigaSOM) pkg>`). Now, you can use the dependency. For instance: - -```julia -julia> using DataFrames -``` - -:warning: If you do not `activate` the environment before using any of the dependencies, you will see a red error messages prompting you to install the dependency explicity. diff --git a/docs/src/index.md b/docs/src/index.md index b830d185..9d06c684 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,67 +1,7 @@ -![GigaSOM.jl](https://webdav-r3lab.uni.lu/public/GigaSOM/img/logo-GigaSOM.jl.png?maxAge=0) -# GigaSOM.jl - Huge-scale, high-performance flow cytometry clustering in Julia +# GigaSOM.jl documentation - -GigaSOM.jl allows painless analysis of huge-scale clinical studies, scaling down the software limitations that usually prevent work with large datasets. It can be viewed as a work-alike of FlowSOM, suitable for loading billions of cells and running the analyses in parallel on distributed computer clusters, to gain speed. Most importantly, GigaSOM.jl scales horizontally -- data volume limitations and memory limitations can be solved just by adding more computers to the cluster. That makes it extremely easy to exploit HPC environments, which are becoming increasingly common in computational biology. - -```@raw html - -
- -
- Evolution of the GigaSOM.jl repository (2019-2020) -
-``` - -### Features - -- Horizontal scalability to literal giga-scale datasets (``10^9`` cells!) -- HPC-ready, support for e.g. Slurm -- Standard support for distributed loading, scaling and transforming the FCS3 files -- Batch-SOM based GigaSOM algorithm for clustering -- EmbedSOM for visualizations - -### Background - -You can learn more about the background of GigaSOM.jl in these sections: - -```@contents -Pages = ["background.md"] -``` - -### How to get started? - -You can follow our extensive tutorials here: - -```@contents -Pages = ["tutorials/basicUsage.md", - "tutorials/processingFCSData.md", - "tutorials/distributedProcessing.md", - "tutorials/whereToGoNext.md" -] -``` - -### Functions - -A full reference to all functions is given here: - -```@contents -Pages = ["functions.md"] -``` - -### How to contribute? - -If you want to contribute, please read these guidelines first: - -```@contents -Pages = ["howToContribute.md"] +```@autodocs +Modules = [GigaSOM] +Pages = ["src/GigaSOM.jl"] ``` diff --git a/docs/src/functions.md b/docs/src/reference.md similarity index 68% rename from docs/src/functions.md rename to docs/src/reference.md index 95e64b05..079b7abe 100644 --- a/docs/src/functions.md +++ b/docs/src/reference.md @@ -11,19 +11,19 @@ Pages = ["structs.jl"] ```@autodocs Modules = [GigaSOM] -Pages = ["input.jl", "process.jl", "splitting.jl", "dataops.jl"] +Pages = ["load.jl", "data_utils.jl", "split.jl"] ``` ## SOM training ```@autodocs Modules = [GigaSOM] -Pages = ["core.jl", "trainutils.jl"] +Pages = ["som.jl", "som_utils.jl"] ``` ## Embedding ```@autodocs Modules = [GigaSOM] -Pages = ["embedding.jl"] +Pages = ["embed.jl"] ``` diff --git a/src/GigaSOM.jl b/src/GigaSOM.jl index bf535483..4f2f9d08 100644 --- a/src/GigaSOM.jl +++ b/src/GigaSOM.jl @@ -1,9 +1,24 @@ """ module GigaSOM -Huge-scale, high-performance flow cytometry data clustering. +GigaSOM.jl allows painless analysis of huge-scale flow-cytometry datasets, such +as from large clinical studies. It can be viewed as a work-alike of FlowSOM +package (for R), suitable for loading billions of cells and running the +analyses in parallel on distributed computer clusters, in order to gain speed. + +Most importantly, GigaSOM.jl scales horizontally -- data volume limitations and +memory limitations can be solved just by adding more computers to the cluster. +That makes it extremely easy to exploit HPC environments, which are becoming +increasingly common in computational biology. + +### Features + +- Horizontal scalability to literal giga-scale datasets (``10^9`` cells!) +- HPC-ready, with support for e.g. Slurm +- Standard support for distributed loading, scaling and transforming the FCS3 files +- Batch-SOM based GigaSOM algorithm for clustering +- EmbedSOM for visualizations -See documentation at: """ module GigaSOM @@ -25,7 +40,7 @@ include("structs.jl") include("som.jl") include("som_utils.jl") -include("embedding.jl") +include("embed.jl") include("load.jl") include("split.jl") diff --git a/src/embedding.jl b/src/embed.jl similarity index 100% rename from src/embedding.jl rename to src/embed.jl diff --git a/test/coverage/coverage-summary.jl b/test/coverage/coverage-summary.jl deleted file mode 100644 index a0fa0459..00000000 --- a/test/coverage/coverage-summary.jl +++ /dev/null @@ -1,12 +0,0 @@ - -using Coverage - -cd(joinpath(@__DIR__, "..", "..")) do - processed = process_folder() - covered_lines, total_lines = get_summary(processed) - percentage = covered_lines / total_lines * 100 - println("($(percentage)%) covered") -end - -# submit the report to codecov.io -Codecov.submit_local(Codecov.process_folder(), pwd(), slug = "LCSB-BioCore/GigaSOM.jl") From 6a60ca850c0c487db7badd0789d84e835c4e7f92 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 13:19:22 +0200 Subject: [PATCH 13/61] rename all functions to follow julia snakecase standard (and other sane guidelines) --- src/GigaSOM.jl | 38 ++++++++++---------- src/data_utils.jl | 6 ++-- src/embed.jl | 18 +++++----- src/load.jl | 74 +++++++++++++++++++-------------------- src/som.jl | 58 +++++++++++++++--------------- src/som_utils.jl | 24 ++++++------- src/split.jl | 8 ++--- src/structs.jl | 6 ++-- test/testBatch.jl | 10 +++--- test/testFileSplitting.jl | 6 ++-- test/testInput.jl | 2 +- test/testInputCSV.jl | 10 +++--- test/testLoadPBMC8.jl | 14 ++++---- test/testParallel.jl | 8 ++--- test/testSplitting.jl | 6 ++-- test/testTrainutils.jl | 18 +++++----- 16 files changed, 152 insertions(+), 154 deletions(-) diff --git a/src/GigaSOM.jl b/src/GigaSOM.jl index 4f2f9d08..b0443cd4 100644 --- a/src/GigaSOM.jl +++ b/src/GigaSOM.jl @@ -48,38 +48,36 @@ include("data_utils.jl") #core -export initGigaSOM, trainGigaSOM, mapToGigaSOM +export init, train, assign #trainutils -export linearRadius, expRadius, gaussianKernel, bubbleKernel, thresholdKernel, distMatrix +export radius_linear, radius_exp, kernel_gaussian, kernel_bubble, kernel_threshold, distance_matrix #embedding -export embedGigaSOM +export embed # structs -export Som +export SOM #io/input -export readFlowset, - readFlowFrame, - loadFCS, - loadFCSHeader, - getFCSSize, - loadFCSSizes, - loadFCSSet, - selectFCSColumns, - distributeFCSFileVector, - distributeFileVector, - getCSVSize, - loadCSV, - loadCSVSizes, - loadCSVSet +export load_fcs, + load_fcs_header, + read_fcs_size, + read_fcs_sizes, + load_fcs_distributed, + select_fcs_columns, + fcs_filevector_distribute, + filevector_distribute, + read_csv_size, + load_csv, + read_csv_sizes, + load_csv_distributed #io/splitting -export slicesof, vcollectSlice, collectSlice +export slicesof, vcollect_slice, collect_slice #io/process -export cleanNames!, getMetaData, getMarkerNames +export clean_names!, fcs_column_metadata, fcs_metadata_marker_names #dataops (higher-level operations on data) export dtransform_asinh diff --git a/src/data_utils.jl b/src/data_utils.jl index 7d6c9f9b..aae39ebb 100644 --- a/src/data_utils.jl +++ b/src/data_utils.jl @@ -7,7 +7,7 @@ prefixes an '_' if the name starts with a number. # Arguments: - `mydata`: vector of names (gets modified) """ -function cleanNames!(mydata::Vector{String}) +function clean_names!(mydata::Vector{String}) # replace problematic characters, # put "_" in front of colname in case it starts with a number # avoid duplicate names (add suffixes _2, _3, ...) @@ -37,7 +37,7 @@ Collect the meta data information in a more user friendly format. # Arguments: - `f`: input structure with `.params` and `.data` fields """ -function getMetaData(meta::Dict{String,String})::DataFrame +function fcs_column_metadata(meta::Dict{String,String})::DataFrame # declarations and initializations metaKeys = keys(meta) @@ -85,7 +85,7 @@ Extract suitable raw names (useful for selecting columns) and pretty readable names (useful for humans) from FCS file metadata. """ -function getMarkerNames(meta::DataFrame)::Tuple{Vector{String},Vector{String}} +function fcs_metadata_marker_names(meta::DataFrame)::Tuple{Vector{String},Vector{String}} orig = Array{String}(meta[:, :N]) nice = copy(orig) if hasproperty(meta, :S) diff --git a/src/embed.jl b/src/embed.jl index d5fe293f..a28dd902 100644 --- a/src/embed.jl +++ b/src/embed.jl @@ -20,8 +20,8 @@ Return a data frame with X,Y coordinates of EmbedSOM projection of the data. Data must have the same number of dimensions as the training dataset, and must be normalized using the same parameters. """ -function embedGigaSOM( - som::GigaSOM.Som, +function embed( + som::GigaSOM.SOM, dInfo::Dinfo; knnTreeFun = BruteTree, metric = Euclidean(), @@ -60,15 +60,15 @@ end """ $(TYPEDSIGNATURES) -Overload of `embedGigaSOM` for simple DataFrames and matrices. This slices the +Overload of `embed` for simple DataFrames and matrices. This slices the data using `DistributedArrays`, sends them the workers, and runs normal -`embedGigaSOM`. All data is properly `unscatter`d after the computation. +`embed`. All data is properly `unscatter`d after the computation. # Examples: Produce a 2-column matrix with 2D cell coordinates: ``` -e = embedGigaSOM(som, data) +e = embed(som, data) ``` Plot the result using 2D histogram from Gadfly: @@ -79,8 +79,8 @@ draw(PNG("output.png",20cm,20cm), Geom.histogram2d(xbincount=200, ybincount=200))) ``` """ -function embedGigaSOM( - som::GigaSOM.Som, +function embed( + som::GigaSOM.SOM, data; knnTreeFun = BruteTree, metric = Euclidean(), @@ -93,7 +93,7 @@ function embedGigaSOM( data = Matrix{Float64}(data) dInfo = scatter_array(:GigaSOMembeddingDataVar, data, workers()) - rInfo = embedGigaSOM( + rInfo = embed( som, dInfo, knnTreeFun = knnTreeFun, @@ -116,7 +116,7 @@ Internal function to compute parts of the embedding on a prepared kNN-tree structure (`tree`) and `smooth` converted to `boost`. """ function embedGigaSOM_internal( - som::GigaSOM.Som, + som::GigaSOM.SOM, data::Matrix{Float64}, tree, k::Int64, diff --git a/src/load.jl b/src/load.jl index 93942f85..232f00ea 100644 --- a/src/load.jl +++ b/src/load.jl @@ -3,7 +3,7 @@ $(TYPEDSIGNATURES) Efficiently extract data offsets and keyword dictionary from an FCS file. """ -function loadFCSHeader(fn::String)::Tuple{Vector{Int},Dict{String,String}} +function load_fcs_header(fn::String)::Tuple{Vector{Int},Dict{String,String}} open(fn) do io offsets = FCSFiles.parse_header(io) params = FCSFiles.parse_text(io, offsets[1], offsets[2]) @@ -18,7 +18,7 @@ $(TYPEDSIGNATURES) Convert the offsets and keywords from an FCS file to cell and parameter count, respectively. """ -function getFCSSize(offsets, params)::Tuple{Int,Int} +function read_fcs_size(offsets, params)::Tuple{Int,Int} nData = parse(Int, params["\$TOT"]) nParams = parse(Int, params["\$PAR"]) @@ -50,11 +50,11 @@ $(TYPEDSIGNATURES) Load cell counts in many FCS files at once. Useful as input for `slicesof`. """ -function loadFCSSizes(fns::Vector{String})::Vector{Int} +function read_fcs_sizes(fns::Vector{String})::Vector{Int} [( begin - o, s = loadFCSHeader(fn) - getFCSSize(o, s)[1] + o, s = load_fcs_header(fn) + read_fcs_size(o, s)[1] end ) for fn in fns] end @@ -73,12 +73,12 @@ If `applyCompensation` is set, the function parses and retrieves a spillover matrix (if any valid keyword in the FCS is found that would contain it) and applies it to compensate the data. """ -function loadFCS( +function load_fcs( fn::String; applyCompensation::Bool = true, )::Tuple{Dict{String,String},Matrix{Float64}} fcs = FileIO.load(fn) - meta = getMetaData(fcs.params) + meta = fcs_column_metadata(fcs.params) data = hcat(map(x -> Vector{Float64}(fcs.data[x]), meta[:, :N])...) if applyCompensation spill = getSpillover(fcs.params) @@ -102,12 +102,12 @@ This runs the FCS loading machinery in a distributed way, so that the files `fns` (with full path) are sliced into equal parts and saved as a distributed variable `name` on workers specified by `pids`. -`applyCompensation` is passed to loadFCS function. +`applyCompensation` is passed to load_fcs function. See `slicesof` for description of the slicing. `postLoad` is applied to the loaded FCS file data (and the index) -- use this -function to e.g. filter out certain columns right on loading, using `selectFCSColumns`. +function to e.g. filter out certain columns right on loading, using `select_fcs_columns`. The loaded dataset can be manipulated by the distributed functions, e.g. - `dselect` for removing columns @@ -115,24 +115,24 @@ The loaded dataset can be manipulated by the distributed functions, e.g. - `dtransform_asinh` (and others) for transformation - etc. """ -function loadFCSSet( +function load_fcs_distributed( name::Symbol, fns::Vector{String}, pids = workers(); applyCompensation = true, postLoad = (d, i) -> d, )::Dinfo - slices = slicesof(loadFCSSizes(fns), length(pids)) + slices = slicesof(read_fcs_sizes(fns), length(pids)) dmap( slices, (slice) -> Base.eval( Main, :( begin - $name = vcollectSlice( + $name = vcollect_slice( (i) -> last( $postLoad( - loadFCS($fns[i]; applyCompensation = $applyCompensation), + load_fcs($fns[i]; applyCompensation = $applyCompensation), i, ), ), @@ -150,15 +150,15 @@ end """ $(TYPEDSIGNATURES) -Return a function useful with `loadFCSSet`, which loads only the specified -(prettified) column names from the FCS files. Use `getMetaData`, -`getMarkerNames` and `cleanNames!` to retrieve the usable column names for a +Return a function useful with `load_fcs_distributed`, which loads only the specified +(prettified) column names from the FCS files. Use `fcs_column_metadata`, +`fcs_metadata_marker_names` and `clean_names!` to retrieve the usable column names for a FCS. """ -function selectFCSColumns(selectColnames::Vector{String}) +function select_fcs_columns(selectColnames::Vector{String}) ((metadata, data), idx) -> begin - _, names = getMarkerNames(getMetaData(metadata)) - cleanNames!(names) + _, names = fcs_metadata_marker_names(fcs_column_metadata(metadata)) + clean_names!(names) colIdxs = indexin(selectColnames, names) if any(colIdxs .== nothing) @error "Some columns were not found" @@ -176,19 +176,19 @@ from `fns` the cell comes from. Useful for producing per-file statistics. The vector is saved on workers specified by `pids` as a distributed variable `name`. """ -function distributeFCSFileVector(name::Symbol, fns::Vector{String}, pids = workers())::Dinfo - sizes = loadFCSSizes(fns) +function fcs_filevector_distribute(name::Symbol, fns::Vector{String}, pids = workers())::Dinfo + sizes = read_fcs_sizes(fns) slices = slicesof(sizes, length(pids)) - return distributeFileVector(name, sizes, slices, pids) + return filevector_distribute(name, sizes, slices, pids) end """ $(TYPEDSIGNATURES) -Generalized version of `distributeFCSFileVector` that produces the integer +Generalized version of `fcs_filevector_distribute` that produces the integer vector from any `sizes` and `slices`. """ -function distributeFileVector( +function filevector_distribute( name::Symbol, sizes::Vector{Int}, slices::Vector{Tuple{Int,Int,Int,Int}}, @@ -197,7 +197,7 @@ function distributeFileVector( dmap( slices, (slice) -> - Base.eval(Main, :($name = collectSlice((i) -> fill(i, $sizes[i]), $slice))), + Base.eval(Main, :($name = collect_slice((i) -> fill(i, $sizes[i]), $slice))), pids, ) return Dinfo(name, pids) @@ -211,9 +211,9 @@ Read the dimensions (number of rows and columns, respectively) from a CSV file # Example - getCSVSize("test.csv", header=false) + read_csv_size("test.csv", header=false) """ -function getCSVSize(fn::String; args...)::Tuple{Int,Int} +function read_csv_size(fn::String; args...)::Tuple{Int,Int} n = 0 k = 0 # ideally, this will not try to load the whole CSV in the memory @@ -230,44 +230,44 @@ end $(TYPEDSIGNATURES) Determine number of rows in a list of CSV files (passed as `fns`). Equivalent -to `loadFCSSizes`. +to `read_fcs_sizes`. """ -function loadCSVSizes(fns::Vector{String}; args...)::Vector{Int} - [getCSVSize(fn, types = Float64; args...)[1] for fn in fns] +function read_csv_sizes(fns::Vector{String}; args...)::Vector{Int} + [read_csv_size(fn, types = Float64; args...)[1] for fn in fns] end """ $(TYPEDSIGNATURES) -CSV equivalent of `loadFCS`. The metadata (header, column names) are not +CSV equivalent of `load_fcs`. The metadata (header, column names) are not extracted. `args` are passed to `CSV.read`. """ -function loadCSV(fn::String; args...)::Matrix{Float64} +function load_csv(fn::String; args...)::Matrix{Float64} CSV.read(fn, DataFrame, types = Float64; args...) |> Matrix{Float64} end """ $(TYPEDSIGNATURES) -CSV equivalent of `loadFCSSet`. `csvargs` are passed as keyword arguments to +CSV equivalent of `load_fcs_distributed`. `csvargs` are passed as keyword arguments to CSV-loading functions. """ -function loadCSVSet( +function load_csv_distributed( name::Symbol, fns::Vector{String}, pids = workers(); postLoad = (d, i) -> d, csvargs..., )::Dinfo - slices = slicesof(loadCSVSizes(fns; csvargs...), length(pids)) + slices = slicesof(read_csv_sizes(fns; csvargs...), length(pids)) dmap( slices, (slice) -> Base.eval( Main, :( begin - $name = vcollectSlice( - (i) -> $postLoad(loadCSV($fns[i]; $csvargs...), i), + $name = vcollect_slice( + (i) -> $postLoad(load_csv($fns[i]; $csvargs...), i), $slice, ) nothing diff --git a/src/som.jl b/src/som.jl index d2cc6681..95464c8d 100644 --- a/src/som.jl +++ b/src/som.jl @@ -4,12 +4,12 @@ $(TYPEDSIGNATURES) Initializes a SOM by random selection from the training data. A generic overload that works for matrices and DataFrames that can be coerced to `Matrix{Float64}`. Other arguments are passed to the data-independent -`initGigaSOM`. +`init`. Arguments: - `data`: matrix of data for running the initialization """ -function initGigaSOM(data::Union{Matrix,DataFrame}, args...; kwargs...) +function init(data::Union{Matrix,DataFrame}, args...; kwargs...) d = Matrix{Float64}(data) @@ -17,24 +17,24 @@ function initGigaSOM(data::Union{Matrix,DataFrame}, args...; kwargs...) means = [sum(d[:, i]) / n for i = 1:ncol] sdevs = [sqrt(sum((d[:, i] .- means[i]) .^ 2.0) / n) for i = 1:ncol] - return initGigaSOM(ncol, means, sdevs, args...; kwargs...) + return init(ncol, means, sdevs, args...; kwargs...) end """ $(TYPEDSIGNATURES) -`initGigaSOM` overload for working with distributed-style `Dinfo` +`init` overload for working with distributed-style `Dinfo` data. The rest of the arguments is passed to the data-independent -`initGigaSOM`. +`init`. Arguments: - `data`: a `Dinfo` object with the distributed dataset matrix """ -function initGigaSOM(data::Dinfo, args...; kwargs...) +function init(data::Dinfo, args...; kwargs...) ncol = get_val_from(data.workers[1], :(size($(data.val))[2])) (means, sdevs) = dstat(data, Vector(1:ncol)) - initGigaSOM(ncol, means, sdevs, args...; kwargs...) + init(ncol, means, sdevs, args...; kwargs...) end """ @@ -49,9 +49,9 @@ Arguments: - `seed`: a seed (defaults to random seed from the current default random generator - `rng`: a random number generator to be used (defaults to a `StableRNG` initialized with the `seed`) -Returns: a new `Som` structure +Returns: a new `SOM` structure """ -function initGigaSOM( +function init( ncol::Int64, means::Vector{Float64}, sdevs::Vector{Float64}, @@ -72,7 +72,7 @@ function initGigaSOM( codes[:, col] .+= means[col] end - return Som(codes = codes, xdim = xdim, ydim = ydim, grid = grid) + return SOM(codes = codes, xdim = xdim, ydim = ydim, grid = grid) end @@ -80,31 +80,31 @@ end $(TYPEDSIGNATURES) # Arguments: -- `som`: object of type Som with an initialised som +- `som`: object of type SOM with an initialised som - `dInfo`: `Dinfo` object that describes a loaded dataset -- `kernelFun::function`: optional distance kernel; one of (`bubbleKernel, gaussianKernel`) - default is `gaussianKernel` +- `kernelFun::function`: optional distance kernel; one of (`kernel_bubble, kernel_gaussian`) + default is `kernel_gaussian` - `metric`: Passed as metric argument to the KNN-tree constructor - `somDistFun`: Function for computing the distances in the SOM map - `knnTreeFun`: Constructor of the KNN-tree (e.g. from NearestNeighbors package) - `rStart`: optional training radius. If zero (default), it is computed from the SOM grid size. - `rFinal`: target radius at the last epoch, defaults to 0.1 -- `radiusFun`: Function that generates radius decay, e.g. `linearRadius` or `expRadius(10.0)` +- `radiusFun`: Function that generates radius decay, e.g. `radius_linear` or `radius_exp(10.0)` - `epochs`: number of SOM training iterations (default 10) - `eachEpoch`: a function to call back after each epoch, accepting arguments `(epochNumber, radius, som)`. For simplicity, this gets additionally called once before the first epoch, with `epochNumber` set to zero. """ -function trainGigaSOM( - som::Som, +function train( + som::SOM, dInfo::Dinfo; - kernelFun::Function = gaussianKernel, + kernelFun::Function = kernel_gaussian, metric = Euclidean(), - somDistFun = distMatrix(Chebyshev()), + somDistFun = distance_matrix(Chebyshev()), knnTreeFun = BruteTree, rStart = 0.0, rFinal = 0.1, - radiusFun = expRadius(-5.0), + radiusFun = radius_exp(-5.0), epochs = 20, eachEpoch = (e, r, som) -> nothing, ) @@ -151,17 +151,17 @@ end """ $(TYPEDSIGNATURES) -Overload of `trainGigaSOM` for simple DataFrames and matrices. This slices the -data, distributes them to the workers, and runs normal `trainGigaSOM`. Data is +Overload of `train` for simple DataFrames and matrices. This slices the +data, distributes them to the workers, and runs normal `train`. Data is `unscatter`d after the computation. """ -function trainGigaSOM(som::Som, train; kwargs...) +function train(som::SOM, train; kwargs...) train = Matrix{Float64}(train) #this slices the data into parts and and sends them to workers dInfo = scatter_array(:GigaSOMtrainDataVar, train, workers()) - som_res = trainGigaSOM(som, dInfo; kwargs...) + som_res = train(som, dInfo; kwargs...) unscatter(dInfo) return som_res end @@ -226,8 +226,8 @@ Compute the index of the BMU for each row of the input data. Data must have the same number of dimensions as the training dataset and will be normalised with the same parameters. """ -function mapToGigaSOM( - som::Som, +function assign( + som::SOM, dInfo::Dinfo; knnTreeFun = BruteTree, metric = Euclidean(), @@ -242,11 +242,11 @@ end """ $(TYPEDSIGNATURES) -Overload of `mapToGigaSOM` for simple DataFrames and matrices. This slices the +Overload of `assign` for simple DataFrames and matrices. This slices the data using `DistributedArrays`, sends them the workers, and runs normal -`mapToGigaSOM`. Data is `unscatter`d after the computation. +`assign`. Data is `unscatter`d after the computation. """ -function mapToGigaSOM(som::Som, data; knnTreeFun = BruteTree, metric = Euclidean()) +function assign(som::SOM, data; knnTreeFun = BruteTree, metric = Euclidean()) data = Matrix{Float64}(data) @@ -256,7 +256,7 @@ function mapToGigaSOM(som::Som, data; knnTreeFun = BruteTree, metric = Euclidean end dInfo = scatter_array(:GigaSOMmappingDataVar, data, workers()) - rInfo = mapToGigaSOM(som, dInfo, knnTreeFun = knnTreeFun, metric = metric) + rInfo = assign(som, dInfo, knnTreeFun = knnTreeFun, metric = metric) res = gather_array(rInfo) unscatter(dInfo) unscatter(rInfo) diff --git a/src/som_utils.jl b/src/som_utils.jl index f409ad5c..2a28dcb6 100644 --- a/src/som_utils.jl +++ b/src/som_utils.jl @@ -2,7 +2,7 @@ """ $(TYPEDSIGNATURES) -Return a neighbourhood radius. Use as the `radiusFun` parameter for `trainGigaSOM`. +Return a neighbourhood radius. Use as the `radiusFun` parameter for `train`. # Arguments - `initRadius`: Initial Radius @@ -10,7 +10,7 @@ Return a neighbourhood radius. Use as the `radiusFun` parameter for `trainGigaSO - `iteration`: Training iteration - `epochs`: Total number of epochs """ -function linearRadius( +function radius_linear( initRadius::Float64, finalRadius::Float64, iteration::Int64, @@ -25,17 +25,17 @@ end """ $(TYPEDSIGNATURES) -Return a function to be used as a `radiusFun` of `trainGigaSOM`, which causes +Return a function to be used as a `radiusFun` of `train`, which causes exponencial decay with the selected steepness. -Use: `trainGigaSOM(..., radiusFun = expRadius(0.5))` +Use: `train(..., radiusFun = radius_exp(0.5))` # Arguments - `steepness`: Steepness of exponential descent. Good values range from -100.0 (almost linear) to 100.0 (really quick decay). """ -function expRadius(steepness::Float64 = 0.0) +function radius_exp(steepness::Float64 = 0.0) return (initRadius::Float64, finalRadius::Float64, iteration::Int64, epochs::Int64) -> begin @@ -43,7 +43,7 @@ function expRadius(steepness::Float64 = 0.0) if steepness < -100.0 # prevent floating point underflows - error("Sanity check: steepness too low, use linearRadius instead.") + error("Sanity check: steepness too low, use radius_linear instead.") end # steepness is simulated by moving both points closer to zero @@ -97,7 +97,7 @@ $(TYPEDSIGNATURES) Return the value of normal distribution PDF (σ=`r`, μ=0) at `x` """ -function gaussianKernel(x, r::Float64) +function kernel_gaussian(x, r::Float64) return Distributions.pdf.(Distributions.Normal(0.0, r), x) end @@ -117,17 +117,17 @@ $(TYPEDSIGNATURES) Return a "bubble" (spherical) distribution kernel. """ -function bubbleKernel(x, r::Float64) +function kernel_bubble(x, r::Float64) return bubbleKernelSqScalar.(x .^ 2, r^2) end """ $(TYPEDSIGNATURES) - thresholdKernel(x, r::Float64) + kernel_threshold(x, r::Float64) Simple FlowSOM-like hard-threshold kernel """ -function thresholdKernel(x, r::Float64, maxRatio = 4 / 5, zero = 1e-6) +function kernel_threshold(x, r::Float64, maxRatio = 4 / 5, zero = 1e-6) if r >= maxRatio * maximum(x) #prevent smoothing everything to a single point r = maxRatio * maximum(x) end @@ -139,9 +139,9 @@ $(TYPEDSIGNATURES) Return a function that uses the `metric` (compatible with metrics from package `Distances`) calculates distance matrixes from normal row-wise data matrices, using the `metric`. -Use as a parameter of `trainGigaSOM`. +Use as a parameter of `train`. """ -function distMatrix(metric = Chebyshev()) +function distance_matrix(metric = Chebyshev()) return (grid::Matrix{Float64}) -> begin n = size(grid, 1) dm = zeros(Float64, n, n) diff --git a/src/split.jl b/src/split.jl index 56c31dff..b7f929cc 100644 --- a/src/split.jl +++ b/src/split.jl @@ -58,11 +58,11 @@ therefore named _v_collect (the slicing and concatenation is _v_ertical). The actual data content and loading method is abstracted out -- function `loadMtx` gets the index of the input part that it is required to fetch (e.g. index of one FCS file), and is expected to return that input part as a whole -matrix. `vcollectSlice` correctly calls this function as required and extracts +matrix. `vcollect_slice` correctly calls this function as required and extracts relevant portions of the matrices, so that at the end the whole slice can be pasted together. """ -function vcollectSlice( +function vcollect_slice( loadMtx, (startFile, startOff, finalFile, finalOff)::Tuple{Int,Int,Int,Int}, )::Matrix @@ -81,9 +81,9 @@ end """ $(TYPEDSIGNATURES) -Alternative of `vcollectSlice` for 1D vectors. +Alternative of `vcollect_slice` for 1D vectors. """ -function collectSlice( +function collect_slice( loadVec, (startFile, startOff, finalFile, finalOff)::Tuple{Int,Int,Int,Int}, )::Vector diff --git a/src/structs.jl b/src/structs.jl index 35c57086..4bc05faf 100644 --- a/src/structs.jl +++ b/src/structs.jl @@ -6,14 +6,14 @@ Structure to hold all data of a trained SOM. # Fields: $(TYPEDFIELDS) """ -mutable struct Som +mutable struct SOM codes::Matrix{Float64} xdim::Int ydim::Int numCodes::Int grid::Matrix{Float64} - Som(; + SOM(; codes::Matrix{Float64}, xdim::Int, ydim::Int, @@ -22,7 +22,7 @@ mutable struct Som ) = new(codes, xdim, ydim, numCodes, grid) end -Base.copy(som::Som) = Som( +Base.copy(som::SOM) = SOM( codes = som.codes, xdim = som.xdim, ydim = som.ydim, diff --git a/test/testBatch.jl b/test/testBatch.jl index 2f8f3587..94fcf8c1 100644 --- a/test/testBatch.jl +++ b/test/testBatch.jl @@ -1,11 +1,11 @@ @testset "Single-CPU batch processing" begin - som = initGigaSOM(pbmc8_data, 10, 10, seed = 1234) + som = init(pbmc8_data, 10, 10, seed = 1234) #check whether the distributed version works the same save_at(1, :test, pbmc8_data) - som2 = initGigaSOM(Dinfo(:test, [1]), 10, 10, seed = 1234) + som2 = init(Dinfo(:test, [1]), 10, 10, seed = 1234) @test som.codes == som2.codes remove_from(1, :test) @@ -16,11 +16,11 @@ @test som.numCodes == 100 end - som = trainGigaSOM(som, pbmc8_data, epochs = 1) + som = train(som, pbmc8_data, epochs = 1) - winners = mapToGigaSOM(som, pbmc8_data) + winners = assign(som, pbmc8_data) - embed = embedGigaSOM(som, pbmc8_data, k = 10, smooth = 0.1, adjust = 2.3, m = 4.5) + embed = embed(som, pbmc8_data, k = 10, smooth = 0.1, adjust = 2.3, m = 4.5) @testset "Check results" begin codes = som.codes diff --git a/test/testFileSplitting.jl b/test/testFileSplitting.jl index b2e089be..07939aa7 100644 --- a/test/testFileSplitting.jl +++ b/test/testFileSplitting.jl @@ -7,7 +7,7 @@ # try load it by parts of different size and scrutinize the parts for splitSize = 1:8 - di = loadFCSSet(:test, md[:, :file_name], W[1:splitSize]) + di = load_fcs_distributed(:test, md[:, :file_name], W[1:splitSize]) dselect(di, fcsAntigens, antigens) cols = Vector(1:length(antigens)) dtransform_asinh(di, cols, 5) @@ -18,8 +18,8 @@ @test sum(splits) == size(pbmc8_data, 1) @test minimum(splits) + 1 >= maximum(splits) - sizes = loadFCSSizes(md[:, :file_name]) - dis = distributeFCSFileVector(:testF, md[:, :file_name], W[1:splitSize]) + sizes = read_fcs_sizes(md[:, :file_name]) + dis = fcs_filevector_distribute(:testF, md[:, :file_name], W[1:splitSize]) @test dcount(length(sizes), dis) == sizes @test dcount_buckets(length(sizes), dis, length(sizes), dis) == Matrix{Int}(LinearAlgebra.Diagonal(sizes)) diff --git a/test/testInput.jl b/test/testInput.jl index 4dc1a667..e9d1d3bb 100644 --- a/test/testInput.jl +++ b/test/testInput.jl @@ -2,7 +2,7 @@ checkDir() @testset "File loading with compensation" begin - _, data = loadFCS("test-compensation.fcs") + _, data = load_fcs("test-compensation.fcs") @test isapprox( data[:, 7:18], [ diff --git a/test/testInputCSV.jl b/test/testInputCSV.jl index 7477da63..523cf573 100644 --- a/test/testInputCSV.jl +++ b/test/testInputCSV.jl @@ -2,21 +2,21 @@ @testset "CSV loading" begin files = [refDataPath * "/refBatchDfCodes.csv", refDataPath * "/refParallelDfCodes.csv"] - rows, cols = getCSVSize(files[1], header = true) + rows, cols = read_csv_size(files[1], header = true) @test rows == 10 @test cols == 10 - data1 = loadCSV(files[1], header = true) - data2 = loadCSV(files[2], header = true) + data1 = load_csv(files[1], header = true) + data2 = load_csv(files[2], header = true) @test typeof(data1) == Matrix{Float64} @test size(data1) == (10, 10) - sizes = loadCSVSizes(files, header = true) + sizes = read_csv_sizes(files, header = true) @test sizes == [10, 10] W = addprocs(3) @everywhere using GigaSOM - di = loadCSVSet(:csvTest, files, W, header = true) + di = load_csv_distributed(:csvTest, files, W, header = true) @test gather_array(di) == vcat(data1, data2) sizes = dmapreduce(di, size, vcat) dims = map(last, sizes) diff --git a/test/testLoadPBMC8.jl b/test/testLoadPBMC8.jl index c43d2f5c..14cc76c7 100644 --- a/test/testLoadPBMC8.jl +++ b/test/testLoadPBMC8.jl @@ -57,12 +57,12 @@ md = DataFrame(XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = t panel = DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)) antigens = panel[panel[:, :Lineage] .== 1, :Antigen] -_, fcsParams = loadFCSHeader(md[1, :file_name]) -_, fcsAntigens = getMarkerNames(getMetaData(fcsParams)) -cleanNames!(antigens) -cleanNames!(fcsAntigens) +_, fcsParams = load_fcs_header(md[1, :file_name]) +_, fcsAntigens = fcs_metadata_marker_names(fcs_column_metadata(fcsParams)) +clean_names!(antigens) +clean_names!(fcsAntigens) -di = loadFCSSet(:fcsData, md[:, :file_name], [myid()]) +di = load_fcs_distributed(:fcsData, md[:, :file_name], [myid()]) #prepare the data a bit dselect(di, fcsAntigens, antigens) @@ -74,11 +74,11 @@ pbmc8_data = gather_array(di) unscatter(di) @testset "load-time columns normalization" begin - di = loadFCSSet( + di = load_fcs_distributed( :fcsData, md[:, :file_name], [myid()], - postLoad = selectFCSColumns(antigens), + postLoad = select_fcs_columns(antigens), ) dtransform_asinh(di, cols, 5) dscale(di, cols) diff --git a/test/testParallel.jl b/test/testParallel.jl index 0fc3153e..c4f45857 100644 --- a/test/testParallel.jl +++ b/test/testParallel.jl @@ -4,7 +4,7 @@ W = addprocs(2) @everywhere using GigaSOM - som = initGigaSOM(pbmc8_data, 10, 10, seed = 1234) + som = init(pbmc8_data, 10, 10, seed = 1234) @testset "Check SOM dimensions" begin @test size(som.codes) == (100, 10) @@ -13,11 +13,11 @@ @test som.numCodes == 100 end - som = trainGigaSOM(som, pbmc8_data, epochs = 2, rStart = 6.0) + som = train(som, pbmc8_data, epochs = 2, rStart = 6.0) - winners = mapToGigaSOM(som, pbmc8_data) + winners = assign(som, pbmc8_data) - embed = embedGigaSOM(som, pbmc8_data) + embed = embed(som, pbmc8_data) @testset "Check results" begin codes = som.codes diff --git a/test/testSplitting.jl b/test/testSplitting.jl index 50ecf5e4..165fb80d 100644 --- a/test/testSplitting.jl +++ b/test/testSplitting.jl @@ -34,8 +34,8 @@ @testset "slice collection" begin s = slicesof([4, 4], 3) - @test vcollectSlice(i -> repeat([i], 4), s[1])[:, 1] == [1, 1, 1] - @test vcollectSlice(i -> repeat([i], 4), s[2])[:, 1] == [1, 2, 2] - @test vcollectSlice(i -> repeat([i], 4), s[3])[:, 1] == [2, 2] + @test vcollect_slice(i -> repeat([i], 4), s[1])[:, 1] == [1, 1, 1] + @test vcollect_slice(i -> repeat([i], 4), s[2])[:, 1] == [1, 2, 2] + @test vcollect_slice(i -> repeat([i], 4), s[3])[:, 1] == [2, 2] end end diff --git a/test/testTrainutils.jl b/test/testTrainutils.jl index 57b3d58e..41454d56 100644 --- a/test/testTrainutils.jl +++ b/test/testTrainutils.jl @@ -7,31 +7,31 @@ @testset "Kernels" begin @test isapprox( - gaussianKernel(Vector{Float64}(1:7), 2.0), + kernel_gaussian(Vector{Float64}(1:7), 2.0), [0.1760326, 0.1209853, 0.0647587, 0.0269954, 0.0087641, 0.0022159, 0.0004363], atol = 1e-4, ) @test isapprox( - bubbleKernel(Vector{Float64}(1:6), 5.0), + kernel_bubble(Vector{Float64}(1:6), 5.0), [0.979796, 0.916515, 0.8, 0.6, 0, 0], atol = 0.001, ) @test isapprox( - thresholdKernel(Vector{Float64}(1:10), 5.0), + kernel_threshold(Vector{Float64}(1:10), 5.0), [1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], atol = 1e-3, ) @test isapprox( - thresholdKernel(Vector{Float64}(1:10), 10.1), + kernel_threshold(Vector{Float64}(1:10), 10.1), [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0], atol = 1e-3, ) end @testset "Radii-generating functions" begin - radii1 = expRadius().(5.0, 0.5, Vector(1:10), 10) - radii2 = expRadius(-10.0).(10.0, 0.1, Vector(1:20), 20) - radii3 = linearRadius.(50.0, 0.001, Vector(1:30), 30) + radii1 = radius_exp().(5.0, 0.5, Vector(1:10), 10) + radii2 = radius_exp(-10.0).(10.0, 0.1, Vector(1:20), 20) + radii3 = radius_linear.(50.0, 0.001, Vector(1:30), 30) @test isapprox(radii1[1], 5.0) @test isapprox(radii1[10], 0.5) @test isapprox(radii2[1], 10.0) @@ -43,9 +43,9 @@ @test all(isapprox.(radii3[1:29] .- radii3[2:30], radii3[1] - radii3[2])) end - @testset "distMatrix" begin + @testset "distance_matrix" begin g = GigaSOM.gridRectangular(2, 2) - dm = GigaSOM.distMatrix(Euclidean())(g) + dm = GigaSOM.distance_matrix(Euclidean())(g) @test size(dm) == (4, 4) @test all([dm[i, i] == 0 for i = 1:4]) end From bd928d378b577b8fdb67e2d127f92254c862ea97 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 13:20:31 +0200 Subject: [PATCH 14/61] don't do exports centrally --- src/GigaSOM.jl | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/src/GigaSOM.jl b/src/GigaSOM.jl index b0443cd4..3514837f 100644 --- a/src/GigaSOM.jl +++ b/src/GigaSOM.jl @@ -46,40 +46,4 @@ include("load.jl") include("split.jl") include("data_utils.jl") - -#core -export init, train, assign - -#trainutils -export radius_linear, radius_exp, kernel_gaussian, kernel_bubble, kernel_threshold, distance_matrix - -#embedding -export embed - -# structs -export SOM - -#io/input -export load_fcs, - load_fcs_header, - read_fcs_size, - read_fcs_sizes, - load_fcs_distributed, - select_fcs_columns, - fcs_filevector_distribute, - filevector_distribute, - read_csv_size, - load_csv, - read_csv_sizes, - load_csv_distributed - -#io/splitting -export slicesof, vcollect_slice, collect_slice - -#io/process -export clean_names!, fcs_column_metadata, fcs_metadata_marker_names - -#dataops (higher-level operations on data) -export dtransform_asinh - end # module From 8237b73764b82914d08e83e0d673831100809db7 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 13:32:42 +0200 Subject: [PATCH 15/61] add a few exports here and there, cleanup more names --- src/data_utils.jl | 17 ++++++++++++++--- src/embed.jl | 2 ++ src/load.jl | 12 +++++++++++- src/som.jl | 27 ++++++++++++--------------- src/som_utils.jl | 37 ++++++++++++++++++------------------- src/structs.jl | 2 ++ test/testTrainutils.jl | 6 +++--- 7 files changed, 62 insertions(+), 41 deletions(-) diff --git a/src/data_utils.jl b/src/data_utils.jl index aae39ebb..a642a1a2 100644 --- a/src/data_utils.jl +++ b/src/data_utils.jl @@ -29,6 +29,8 @@ function clean_names!(mydata::Vector{String}) end end +export clean_names! + """ $(TYPEDSIGNATURES) @@ -78,6 +80,8 @@ function fcs_column_metadata(meta::Dict{String,String})::DataFrame return df end +export fcs_column_metadata + """ $(TYPEDSIGNATURES) @@ -98,6 +102,7 @@ function fcs_metadata_marker_names(meta::DataFrame)::Tuple{Vector{String},Vector return (orig, nice) end +export fcs_metadata_marker_names """ $(TYPEDSIGNATURES) @@ -110,12 +115,14 @@ function compensate!(data::Matrix{Float64}, spillover::Matrix{Float64}, cols::Ve data[:, cols] = data[:, cols] * inv(spillover) end +export compensate! + """ $(TYPEDSIGNATURES) Parses the spillover matrix from the string from FCS parameter value. """ -function parseSpillover(str::String)::Tuple{Vector{String},Matrix{Float64}} +function parse_fcs_spillover(str::String)::Tuple{Vector{String},Matrix{Float64}} fields = split(str, ',') n = parse(Int, fields[1]) if length(fields) != 1 + n + n * n @@ -136,18 +143,20 @@ Get a spillover matrix from FCS `params`. Returns a pair with description of columns to be applied, and with the actual spillover matrix. Returns `nothing` in case spillover is not present. """ -function getSpillover( +function fcs_spillover( params::Dict{String,String}, )::Union{Tuple{Vector{String},Matrix{Float64}},Nothing} spillNames = ["\$SPILL", "\$SPILLOVER", "SPILL", "SPILLOVER"] for i in spillNames if in(i, keys(params)) - return parseSpillover(params[i]) + return parse_fcs_spillover(params[i]) end end return nothing end +export fcs_spillover + """ $(TYPEDSIGNATURES) @@ -156,3 +165,5 @@ Transform columns of the dataset by asinh transformation with `cofactor`. function dtransform_asinh(dInfo::Dinfo, columns::Vector{Int}, cofactor = 5) dapply_cols(dInfo, (v, _) -> asinh.(v ./ cofactor), columns) end + +export dtransform_asinh diff --git a/src/embed.jl b/src/embed.jl index a28dd902..477c8b83 100644 --- a/src/embed.jl +++ b/src/embed.jl @@ -264,3 +264,5 @@ function embedGigaSOM_internal( return e end + +export embed diff --git a/src/load.jl b/src/load.jl index 232f00ea..8a492f69 100644 --- a/src/load.jl +++ b/src/load.jl @@ -81,7 +81,7 @@ function load_fcs( meta = fcs_column_metadata(fcs.params) data = hcat(map(x -> Vector{Float64}(fcs.data[x]), meta[:, :N])...) if applyCompensation - spill = getSpillover(fcs.params) + spill = fcs_spillover(fcs.params) if spill != nothing names, mtx = spill cols = indexin(names, meta[:, :N]) @@ -95,6 +95,8 @@ function load_fcs( return (fcs.params, data) end +export load_fcs + """ $(TYPEDSIGNATURES) @@ -147,6 +149,8 @@ function load_fcs_distributed( return Dinfo(name, pids) end +export load_fcs_distributed + """ $(TYPEDSIGNATURES) @@ -168,6 +172,8 @@ function select_fcs_columns(selectColnames::Vector{String}) end end +export select_fcs_columns + """ $(TYPEDSIGNATURES) @@ -246,6 +252,8 @@ function load_csv(fn::String; args...)::Matrix{Float64} CSV.read(fn, DataFrame, types = Float64; args...) |> Matrix{Float64} end +export load_csv + """ $(TYPEDSIGNATURES) @@ -278,3 +286,5 @@ function load_csv_distributed( ) return Dinfo(name, pids) end + +export load_csv_distributed diff --git a/src/som.jl b/src/som.jl index 95464c8d..2fe2b317 100644 --- a/src/som.jl +++ b/src/som.jl @@ -62,7 +62,7 @@ function init( ) numCodes = xdim * ydim - grid = gridRectangular(xdim, ydim) + grid = grid_rectangular(xdim, ydim) # Initialize with an unbiased random gaussian with same mean/sd as the data # in each dimension @@ -75,6 +75,7 @@ function init( return SOM(codes = codes, xdim = xdim, ydim = ydim, grid = grid) end +export init """ $(TYPEDSIGNATURES) @@ -126,7 +127,7 @@ function train( for epoch = 1:epochs @debug "Epoch $epoch..." - numerator, denominator = distributedEpoch( + numerator, denominator = run_epoch_distributed( dInfo, result_som.codes, knnTreeFun(Array{Float64,2}(transpose(result_som.codes)), metric), @@ -166,6 +167,7 @@ function train(som::SOM, train; kwargs...) return som_res end +export train """ $(TYPEDSIGNATURES) @@ -177,7 +179,7 @@ vectors and the adjustment in radius after each epoch. - `codes`: Codebook - `tree`: knn-compatible tree built upon the codes """ -function doEpoch(x::Array{Float64,2}, codes::Array{Float64,2}, tree) +function run_epoch(x::Array{Float64,2}, codes::Array{Float64,2}, tree) # initialise numerator and denominator with 0's sumNumerator = zeros(Float64, size(codes)) @@ -199,18 +201,17 @@ end """ $(TYPEDSIGNATURES) -Execute the `doEpoch` in parallel on workers described by `dInfo` and collect +Execute the `run_epoch` in parallel on workers described by `dInfo` and collect the results. Returns pair of numerator and denominator matrices. """ -function distributedEpoch(dInfo::Dinfo, codes::Matrix{Float64}, tree) +function run_epoch_distributed(dInfo::Dinfo, codes::Matrix{Float64}, tree) return dmapreduce( dInfo, - (data) -> doEpoch(data, codes, tree), + (data) -> run_epoch(data, codes, tree), ((n1, d1), (n2, d2)) -> (n1 + n2, d1 + d2), ) end - """ $(TYPEDSIGNATURES) @@ -263,16 +264,12 @@ function assign(som::SOM, data; knnTreeFun = BruteTree, metric = Euclidean()) return DataFrame(index = res) end +export assign + """ $(TYPEDSIGNATURES) Convert iteration ID and epoch number to relative time in training. """ -function scaleEpochTime(iteration::Int64, epochs::Int64) - # prevent division by zero on 1-epoch training - if epochs > 1 - epochs -= 1 - end - - return Float64(iteration - 1) / Float64(epochs) -end +scaled_epoch_time(iteration::Int64, epochs::Int64) = + Float64(iteration - 1) / Float64(max(epochs, 1)) diff --git a/src/som_utils.jl b/src/som_utils.jl index 2a28dcb6..d69982e8 100644 --- a/src/som_utils.jl +++ b/src/som_utils.jl @@ -17,10 +17,11 @@ function radius_linear( epochs::Int64, ) - scaledTime = scaleEpochTime(iteration, epochs) + scaledTime = scaled_epoch_time(iteration, epochs) return initRadius * (1 - scaledTime) + finalRadius * scaledTime end +export radius_linear """ $(TYPEDSIGNATURES) @@ -39,7 +40,7 @@ function radius_exp(steepness::Float64 = 0.0) return (initRadius::Float64, finalRadius::Float64, iteration::Int64, epochs::Int64) -> begin - scaledTime = scaleEpochTime(iteration, epochs) + scaledTime = scaled_epoch_time(iteration, epochs) if steepness < -100.0 # prevent floating point underflows @@ -62,6 +63,7 @@ function radius_exp(steepness::Float64 = 0.0) end end +export radius_exp """ $(TYPEDSIGNATURES) @@ -79,7 +81,7 @@ The first neuron sits at (0,0). - `xdim`: number of neurons in x-direction - `ydim`: number of neurons in y-direction """ -function gridRectangular(xdim, ydim) +function grid_rectangular(xdim, ydim) grid = zeros(Float64, (xdim * ydim, 2)) for ix = 1:xdim @@ -91,24 +93,19 @@ function gridRectangular(xdim, ydim) return grid end +export grid_rectangular """ $(TYPEDSIGNATURES) Return the value of normal distribution PDF (σ=`r`, μ=0) at `x` """ -function kernel_gaussian(x, r::Float64) +kernel_gaussian(x, r::Float64) = + Distributions.pdf.(Distributions.Normal(0.0, r), x) - return Distributions.pdf.(Distributions.Normal(0.0, r), x) -end +export kernel_gaussian -function bubbleKernelSqScalar(x::Float64, r::Float64) - if x >= r - return 0 - else - return sqrt(1 - x / r) - end -end +bubble_kernel_squared_scalar(x::Float64, r::Float64) = x >= r ? 0 : sqrt(1 - x / r) """ @@ -117,9 +114,9 @@ $(TYPEDSIGNATURES) Return a "bubble" (spherical) distribution kernel. """ -function kernel_bubble(x, r::Float64) - return bubbleKernelSqScalar.(x .^ 2, r^2) -end +kernel_bubble(x, r::Float64) = bubbleKernelSqScalar.(x .^ 2, r^2) + +export kernel_bubble """ $(TYPEDSIGNATURES) @@ -134,6 +131,8 @@ function kernel_threshold(x, r::Float64, maxRatio = 4 / 5, zero = 1e-6) return zero .+ (x .<= r) end +export kernel_threshold + """ $(TYPEDSIGNATURES) @@ -141,8 +140,7 @@ Return a function that uses the `metric` (compatible with metrics from package ` Use as a parameter of `train`. """ -function distance_matrix(metric = Chebyshev()) - return (grid::Matrix{Float64}) -> begin +distance_matrix(metric = Chebyshev()) = (grid::Matrix{Float64}) -> begin n = size(grid, 1) dm = zeros(Float64, n, n) @@ -154,4 +152,5 @@ function distance_matrix(metric = Chebyshev()) return dm::Matrix{Float64} end -end + +export distance_matrix diff --git a/src/structs.jl b/src/structs.jl index 4bc05faf..026a8e33 100644 --- a/src/structs.jl +++ b/src/structs.jl @@ -22,6 +22,8 @@ mutable struct SOM ) = new(codes, xdim, ydim, numCodes, grid) end +export SOM + Base.copy(som::SOM) = SOM( codes = som.codes, xdim = som.xdim, diff --git a/test/testTrainutils.jl b/test/testTrainutils.jl index 41454d56..470b0c2c 100644 --- a/test/testTrainutils.jl +++ b/test/testTrainutils.jl @@ -1,7 +1,7 @@ @testset "SOM training helper functions" begin - @testset "gridRectangular" begin - grid = GigaSOM.gridRectangular(5, 5) + @testset "grid_rectangular" begin + grid = GigaSOM.grid_rectangular(5, 5) @test size(grid) == (25, 2) end @@ -44,7 +44,7 @@ end @testset "distance_matrix" begin - g = GigaSOM.gridRectangular(2, 2) + g = GigaSOM.grid_rectangular(2, 2) dm = GigaSOM.distance_matrix(Euclidean())(g) @test size(dm) == (4, 4) @test all([dm[i, i] == 0 for i = 1:4]) From 27489b160de0d8424f5a411e43380d71a5880100 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 21 Aug 2025 13:35:59 +0200 Subject: [PATCH 16/61] fmt --- src/load.jl | 6 +++++- src/som_utils.jl | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/load.jl b/src/load.jl index 8a492f69..08f90fbd 100644 --- a/src/load.jl +++ b/src/load.jl @@ -182,7 +182,11 @@ from `fns` the cell comes from. Useful for producing per-file statistics. The vector is saved on workers specified by `pids` as a distributed variable `name`. """ -function fcs_filevector_distribute(name::Symbol, fns::Vector{String}, pids = workers())::Dinfo +function fcs_filevector_distribute( + name::Symbol, + fns::Vector{String}, + pids = workers(), +)::Dinfo sizes = read_fcs_sizes(fns) slices = slicesof(sizes, length(pids)) return filevector_distribute(name, sizes, slices, pids) diff --git a/src/som_utils.jl b/src/som_utils.jl index d69982e8..b57a1cf9 100644 --- a/src/som_utils.jl +++ b/src/som_utils.jl @@ -100,8 +100,7 @@ $(TYPEDSIGNATURES) Return the value of normal distribution PDF (σ=`r`, μ=0) at `x` """ -kernel_gaussian(x, r::Float64) = - Distributions.pdf.(Distributions.Normal(0.0, r), x) +kernel_gaussian(x, r::Float64) = Distributions.pdf.(Distributions.Normal(0.0, r), x) export kernel_gaussian @@ -140,7 +139,8 @@ Return a function that uses the `metric` (compatible with metrics from package ` Use as a parameter of `train`. """ -distance_matrix(metric = Chebyshev()) = (grid::Matrix{Float64}) -> begin +distance_matrix(metric = Chebyshev()) = + (grid::Matrix{Float64}) -> begin n = size(grid, 1) dm = zeros(Float64, n, n) From 1b2b6769b1b2c20c230d0a40c6648ea2f74f9988 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 13:22:17 +0200 Subject: [PATCH 17/61] fix stuff and tests --- src/load.jl | 4 ++-- src/som.jl | 8 ++++---- src/som_utils.jl | 2 +- test/testBatch.jl | 4 ++-- test/testFileSplitting.jl | 2 ++ test/testInputCSV.jl | 2 ++ test/testLoadPBMC8.jl | 2 ++ test/testParallel.jl | 4 ++-- test/testSplitting.jl | 2 ++ 9 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/load.jl b/src/load.jl index 08f90fbd..0ca72b22 100644 --- a/src/load.jl +++ b/src/load.jl @@ -131,7 +131,7 @@ function load_fcs_distributed( Main, :( begin - $name = vcollect_slice( + $name = GigaSOM.vcollect_slice( (i) -> last( $postLoad( load_fcs($fns[i]; applyCompensation = $applyCompensation), @@ -278,7 +278,7 @@ function load_csv_distributed( Main, :( begin - $name = vcollect_slice( + $name = GigaSOM.vcollect_slice( (i) -> $postLoad(load_csv($fns[i]; $csvargs...), i), $slice, ) diff --git a/src/som.jl b/src/som.jl index 2fe2b317..915a539c 100644 --- a/src/som.jl +++ b/src/som.jl @@ -156,12 +156,12 @@ Overload of `train` for simple DataFrames and matrices. This slices the data, distributes them to the workers, and runs normal `train`. Data is `unscatter`d after the computation. """ -function train(som::SOM, train; kwargs...) +function train(som::SOM, train_data; kwargs...) - train = Matrix{Float64}(train) + train_data = Matrix{Float64}(train_data) #this slices the data into parts and and sends them to workers - dInfo = scatter_array(:GigaSOMtrainDataVar, train, workers()) + dInfo = scatter_array(:GigaSOMtrainDataVar, train_data, workers()) som_res = train(som, dInfo; kwargs...) unscatter(dInfo) return som_res @@ -272,4 +272,4 @@ $(TYPEDSIGNATURES) Convert iteration ID and epoch number to relative time in training. """ scaled_epoch_time(iteration::Int64, epochs::Int64) = - Float64(iteration - 1) / Float64(max(epochs, 1)) + Float64(iteration - 1) / Float64(max(epochs - 1, 1)) diff --git a/src/som_utils.jl b/src/som_utils.jl index b57a1cf9..0b66da9b 100644 --- a/src/som_utils.jl +++ b/src/som_utils.jl @@ -113,7 +113,7 @@ $(TYPEDSIGNATURES) Return a "bubble" (spherical) distribution kernel. """ -kernel_bubble(x, r::Float64) = bubbleKernelSqScalar.(x .^ 2, r^2) +kernel_bubble(x, r::Float64) = bubble_kernel_squared_scalar.(x .^ 2, r^2) export kernel_bubble diff --git a/test/testBatch.jl b/test/testBatch.jl index 94fcf8c1..12b0b99a 100644 --- a/test/testBatch.jl +++ b/test/testBatch.jl @@ -20,7 +20,7 @@ winners = assign(som, pbmc8_data) - embed = embed(som, pbmc8_data, k = 10, smooth = 0.1, adjust = 2.3, m = 4.5) + e = embed(som, pbmc8_data, k = 10, smooth = 0.1, adjust = 2.3, m = 4.5) @testset "Check results" begin codes = som.codes @@ -28,7 +28,7 @@ dfCodes = DataFrame(codes, :auto) rename!(dfCodes, Symbol.(antigens)) - dfEmbed = DataFrame(embed, :auto) + dfEmbed = DataFrame(e, :auto) CSV.write(genDataPath * "/batchDfCodes.csv", dfCodes) CSV.write(genDataPath * "/batchWinners.csv", winners) CSV.write(genDataPath * "/batchEmbedded.csv", dfEmbed) diff --git a/test/testFileSplitting.jl b/test/testFileSplitting.jl index 07939aa7..30063c9a 100644 --- a/test/testFileSplitting.jl +++ b/test/testFileSplitting.jl @@ -1,3 +1,5 @@ +import GigaSOM: read_fcs_sizes + @testset "File splitting" begin W = addprocs(8) diff --git a/test/testInputCSV.jl b/test/testInputCSV.jl index 523cf573..72e607e2 100644 --- a/test/testInputCSV.jl +++ b/test/testInputCSV.jl @@ -1,4 +1,6 @@ +import GigaSOM: read_csv_size, read_csv_sizes + @testset "CSV loading" begin files = [refDataPath * "/refBatchDfCodes.csv", refDataPath * "/refParallelDfCodes.csv"] diff --git a/test/testLoadPBMC8.jl b/test/testLoadPBMC8.jl index 14cc76c7..3e0b2232 100644 --- a/test/testLoadPBMC8.jl +++ b/test/testLoadPBMC8.jl @@ -1,4 +1,6 @@ +import GigaSOM: load_fcs_header + # This loads the PBMC8 dataset that is used for later tests checkDir() diff --git a/test/testParallel.jl b/test/testParallel.jl index c4f45857..773f86bc 100644 --- a/test/testParallel.jl +++ b/test/testParallel.jl @@ -17,7 +17,7 @@ winners = assign(som, pbmc8_data) - embed = embed(som, pbmc8_data) + e = embed(som, pbmc8_data) @testset "Check results" begin codes = som.codes @@ -25,7 +25,7 @@ dfCodes = DataFrame(codes, :auto) rename!(dfCodes, Symbol.(antigens)) - dfEmbed = DataFrame(embed, :auto) + dfEmbed = DataFrame(e, :auto) CSV.write(genDataPath * "/parallelDfCodes.csv", dfCodes) CSV.write(genDataPath * "/parallelWinners.csv", winners) CSV.write(genDataPath * "/parallelEmbedded.csv", dfEmbed) diff --git a/test/testSplitting.jl b/test/testSplitting.jl index 165fb80d..d1d60aee 100644 --- a/test/testSplitting.jl +++ b/test/testSplitting.jl @@ -1,4 +1,6 @@ +import GigaSOM: slicesof, vcollect_slice + @testset "Dataset splitting helpers" begin @testset "slice computation" begin From d7cd8ddb0d4f85957fe6a803abcd641cd6ef88f4 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 13:26:08 +0200 Subject: [PATCH 18/61] forgotten import --- test/testFileSplitting.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/testFileSplitting.jl b/test/testFileSplitting.jl index 30063c9a..24a88ec2 100644 --- a/test/testFileSplitting.jl +++ b/test/testFileSplitting.jl @@ -1,4 +1,4 @@ -import GigaSOM: read_fcs_sizes +import GigaSOM: read_fcs_sizes, fcs_filevector_distribute @testset "File splitting" begin From dfd7822c6305053d0b22d2b459201f4676f7d7d0 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 13:31:51 +0200 Subject: [PATCH 19/61] fix more --- src/data_utils.jl | 3 --- src/load.jl | 6 ++++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/data_utils.jl b/src/data_utils.jl index a642a1a2..c9416f74 100644 --- a/src/data_utils.jl +++ b/src/data_utils.jl @@ -35,9 +35,6 @@ export clean_names! $(TYPEDSIGNATURES) Collect the meta data information in a more user friendly format. - -# Arguments: -- `f`: input structure with `.params` and `.data` fields """ function fcs_column_metadata(meta::Dict{String,String})::DataFrame diff --git a/src/load.jl b/src/load.jl index 0ca72b22..0e41e1a1 100644 --- a/src/load.jl +++ b/src/load.jl @@ -206,8 +206,10 @@ function filevector_distribute( )::Dinfo dmap( slices, - (slice) -> - Base.eval(Main, :($name = collect_slice((i) -> fill(i, $sizes[i]), $slice))), + (slice) -> Base.eval( + Main, + :($name = GigaSOM.collect_slice((i) -> fill(i, $sizes[i]), $slice)), + ), pids, ) return Dinfo(name, pids) From 2042e7728f1ef0c64dbb509993345d0172e2b64e Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 14:01:39 +0200 Subject: [PATCH 20/61] start rewriting docs --- docs/src/tutorials/basicUsage.md | 18 +++++------ docs/src/tutorials/distributedProcessing.md | 4 +-- docs/src/tutorials/processingFCSData.md | 36 ++++++++++----------- docs/src/tutorials/whereToGoNext.md | 8 ++--- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/src/tutorials/basicUsage.md b/docs/src/tutorials/basicUsage.md index 888da529..d3bca1d9 100644 --- a/docs/src/tutorials/basicUsage.md +++ b/docs/src/tutorials/basicUsage.md @@ -12,14 +12,14 @@ function on the data. The main functions, listed by category: -- [`loadFCS`](@ref) and [`loadFCSSet`](@ref) for loading the data -- [`getMetaData`](@ref) and [`getMarkerNames`](@ref) for accessing the +- [`load_fcs`](@ref) and [`load_fcs_distributed`](@ref) for loading the data +- [`fcs_column_metadata`](@ref) and [`getMarkerNames`](@ref) for accessing the information about data columns stored in FCS files - [`dselect`](@ref), [`dtransform_asinh`](@ref), [`dscale`](@ref) and similar functions for transforming, scaling and preparing the data -- [`initGigaSOM`](@ref) for initializing the self-organizing map, - [`trainGigaSOM`](@ref) for running the SOM training, [`mapToGigaSOM`](@ref) - for classification using the trained SOM, and [`embedGigaSOM`](@ref) for +- [`init`](@ref) for initializing the self-organizing map, + [`train`](@ref) for running the SOM training, [`assign`](@ref) + for classification using the trained SOM, and [`embed`](@ref) for dimensionality reduction to 2D Multiprocessing is done using the `Distributed` package -- if you add @@ -63,8 +63,8 @@ d = randn(10000,4) .+ rand(0:1, 10000, 4).*10 The SOM (of size 20x20) is created and trained as such: ```julia -som = initGigaSOM(d, 20, 20) -som = trainGigaSOM(som, d) +som = init(d, 20, 20) +som = train(som, d) ``` (Note that SOM initialization is randomized; if you want to get the same @@ -94,7 +94,7 @@ som.codes This information can be used to categorize the dataset into clusters: ```julia -mapToGigaSOM(som, d) +assign(som, d) ``` In the result, `index` is a cluster ID for the original datapoint from `d` at @@ -120,7 +120,7 @@ Finally, you can use EmbedSOM dimensionality reduction to convert all multidimensional points to 2D; which can eventually be used to create a good-looking 2D scatterplot. ```julia -e = embedGigaSOM(som,d) +e = embed(som,d) ``` ``` diff --git a/docs/src/tutorials/distributedProcessing.md b/docs/src/tutorials/distributedProcessing.md index 4a0c6aea..72323675 100644 --- a/docs/src/tutorials/distributedProcessing.md +++ b/docs/src/tutorials/distributedProcessing.md @@ -52,11 +52,11 @@ dmapreduce(di, d -> mapslices(sum, d, dims=1), +) ./ dmapreduce(di, x->size(x,1) Finally, for distributed computation of per-cluster mean, the clustering information needs to be distributed as well (Fortunately, that is easy, because -the distributed `mapToGigaSOM` does exactly that). +the distributed `assign` does exactly that). First, compute the clustering: ```julia -mapping = mapToGigaSOM(som, di) +mapping = assign(som, di) dtransform(mapping, m -> metaClusters[m]) ``` diff --git a/docs/src/tutorials/processingFCSData.md b/docs/src/tutorials/processingFCSData.md index 7c891fc7..707bcc56 100644 --- a/docs/src/tutorials/processingFCSData.md +++ b/docs/src/tutorials/processingFCSData.md @@ -1,19 +1,19 @@ # Tutorial 2: Working with cytometry data -You can load any FCS file using [`loadFCS`](@ref) function. For example, the +You can load any FCS file using [`load_fcs`](@ref) function. For example, the Levine dataset ([obtainable here](https://flowrepository.org/id/FR-FCM-ZZPH)) may be loaded as such: ``` -params, data = loadFCS("Levine_13dim.fcs") +params, data = load_fcs("Levine_13dim.fcs") ``` `params` will now contain the list of FCS parameters; you can parse a lot of -interesting information from it using the [`getMetaData`](@ref) function: +interesting information from it using the [`fcs_column_metadata`](@ref) function: ```julia -getMetaData(params) +fcs_column_metadata(params) ``` ``` @@ -45,10 +45,10 @@ the `label` column that contains `NaN` values: ``` data = data[:,1:13] -som = initGigaSOM(data, 16, 16) -som = trainGigaSOM(som, data) -clusters = mapToGigaSOM(som, data) -e = embedGigaSOM(som, data) +som = init(data, 16, 16) +som = train(som, data) +clusters = assign(som, data) +e = embed(som, data) # ... save/plot results, etc... ``` @@ -79,7 +79,7 @@ the function will handle the rest. The result `datainfo` carries informaton about your selected dataset name and its distribution among the cluster. It can be used just as the "data" parameter -in all SOM-related functions again; e.g. as `trainGigaSOM(som, datainfo)`. +in all SOM-related functions again; e.g. as `train(som, datainfo)`. The following example exploits the possibility to actually split the data, and processes the Levine dataset parallelly on 2 workers: @@ -92,8 +92,8 @@ addprocs(2) # add any number of CPUs/tasks/workers you have avai datainfo = loadFCSSet(:levine, ["Levine_13dim.fcs"]) # add more files as needed dselect(datainfo, Vector(1:13)) # select columns that contain expressions (column 14 contains labels) -som = initGigaSOM(datainfo, 20, 20) -som = trainGigaSOM(som, datainfo) +som = init(datainfo, 20, 20) +som = train(som, datainfo) ``` To prevent memory overload of the "master" computation node, the results of all @@ -101,7 +101,7 @@ per-cell operations are also stored in distributed datainfo objects. In this case, the following code does the embedding, but leaves the resulting data safely scattered among the cluster: ```julia -e = embedGigaSOM(som, datainfo) +e = embed(som, datainfo) ``` If you are sure you have enough RAM, you can collect the data to the master @@ -175,7 +175,7 @@ _, fcsParams = loadFCSHeader(md[1, :file_name]) Continue with extracting marker names using the prepared functions: ```julia -_, fcsAntigens = getMarkerNames(getMetaData(fcsParams)) +_, fcsAntigens = getMarkerNames(fcs_column_metadata(fcsParams)) ``` Now, see which antigens we want to use (assume we want only the lineage markers): @@ -224,17 +224,17 @@ With the data prepared, running the SOM algorithm is straightforward: ```julia # randomly initialize the SOM -som = initGigaSOM(di, 16, 16) +som = init(di, 16, 16) # train the SOM for 20 epochs (10 is default, but nothing will happen if the # epochs are slightly overdone) -som = trainGigaSOM(som, di, epochs = 20) +som = train(som, di, epochs = 20) ``` Finally, calculate the clustering: ```julia -somClusters = mapToGigaSOM(som, di) +somClusters = assign(som, di) ``` ### FlowSOM-style metaclustering @@ -257,10 +257,10 @@ metaClusters = ``` The `metaClusters` represent membership of the SOM codes in cluster; these can -be expanded to membership of all cells using [`mapToGigaSOM`](@ref): +be expanded to membership of all cells using [`assign`](@ref): ```julia -mapping = gather_array(mapToGigaSOM(som, di), free=true) +mapping = gather_array(assign(som, di), free=true) clusters = metaClusters[mapping] ``` diff --git a/docs/src/tutorials/whereToGoNext.md b/docs/src/tutorials/whereToGoNext.md index 11e8fdda..ce1f0542 100644 --- a/docs/src/tutorials/whereToGoNext.md +++ b/docs/src/tutorials/whereToGoNext.md @@ -6,18 +6,18 @@ GigaSOM.jl works internally. With real data, you may encounter situations that require deeper digging in parameters and GigaSOM internals. We list several of the most frequently used ones: -- You may want to increase the size of SOM in [`initGigaSOM`](@ref) to get more +- You may want to increase the size of SOM in [`init`](@ref) to get more precise clusters. - You may speed up the computation a lot by using neighborhood-indexing structures -- see package `NearestNeighbors` and parameters `knnTreeFun` of - functions [`trainGigaSOM`](@ref) and [`embedGigaSOM`](@ref) + functions [`train`](@ref) and [`embed`](@ref) - It is adviced to try different settings of SOM training -- of the arguments - of [`trainGigaSOM`](@ref), try modifying the starting/finishing radius + of [`train`](@ref), try modifying the starting/finishing radius (`rStart`, `rFinal`), using a different radius decay (parameter `radiusFun`, try e.g. `linearRadius`) or try a different neighborhood (`kernelFun` and `somDistFun`) or a completely different metric. - You can get a sharper or smoother embedding by varying the amount of neighbors (`k`) and smoothing of the neighborhood (`smooth`) of - [`embedGigaSOM`](@ref). + [`embed`](@ref). - For plotting of really huge data, you may want to try [GigaScatter](https://github.com/LCSB-BioCore/GigaScatter.jl). From c09b87662a44680f74053d453c7b2b7f51a468fe Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 14:15:33 +0200 Subject: [PATCH 21/61] literate rendering --- docs/Project.toml | 1 + docs/make.jl | 58 +++++++++++------- docs/src/assets/parallel.png | Bin 195881 -> 0 bytes .../basicUsage.md | 0 .../distributedProcessing.md | 0 .../processingFCSData.md | 0 .../whereToGoNext.md | 0 docs/src/tutorials/test.jl | 4 ++ 8 files changed, 42 insertions(+), 21 deletions(-) delete mode 100644 docs/src/assets/parallel.png rename docs/src/{tutorials => tutorials-old}/basicUsage.md (100%) rename docs/src/{tutorials => tutorials-old}/distributedProcessing.md (100%) rename docs/src/{tutorials => tutorials-old}/processingFCSData.md (100%) rename docs/src/{tutorials => tutorials-old}/whereToGoNext.md (100%) create mode 100644 docs/src/tutorials/test.jl diff --git a/docs/Project.toml b/docs/Project.toml index 2742e84b..82806167 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,3 +1,4 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" +Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" diff --git a/docs/make.jl b/docs/make.jl index b2c3bf88..7a19b42f 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,26 +1,42 @@ -using Documenter, GigaSOM +using Documenter, Literate, GigaSOM -makedocs( - modules = [GigaSOM], - format = Documenter.HTML( - ansicolor = true, - canonical = "https://lcsb-biocore.github.io/GigaSOM.jl/stable/", - assets = ["assets/gigasomlogotransp.ico"], - ), - sitename = "GigaSOM.jl", - linkcheck = false, - pages = [ - "Home" => "index.md", - "Background" => "background.md", - "Tutorial" => [ - "Introduction" => "tutorials/basicUsage.md", - "Cytometry data" => "tutorials/processingFCSData.md", - "Advanced distributed processing" => "tutorials/distributedProcessing.md", - "Conclusion" => "tutorials/whereToGoNext.md", +examples = + sort(filter(x -> endswith(x, ".jl"), readdir(joinpath(@__DIR__, "src", "tutorials"), join = true))) + +for example in examples + Literate.markdown( + example, + joinpath(@__DIR__, "src"), + repo_root_url = "https://github.com/LCSB-BioCore/GitaSOM.jl/blob/master", + ) +end + +example_mds = first.(splitext.(basename.(examples))) .* ".md" + +withenv("COLUMNS" => 150) do + makedocs( + modules = [GigaSOM], + format = Documenter.HTML( + ansicolor = true, + canonical = "https://lcsb-biocore.github.io/GigaSOM.jl/stable/", + assets = ["assets/gigasomlogotransp.ico"], + ), + sitename = "GigaSOM.jl", + linkcheck = false, + pages = [ + "Home" => "index.md", + "Background" => "background.md", + "Tutorials" => example_mds, + "Tutorial (old)" => [ + "Introduction" => "tutorials-old/basicUsage.md", + "Cytometry data" => "tutorials-old/processingFCSData.md", + "Advanced distributed processing" => "tutorials-old/distributedProcessing.md", + "Conclusion" => "tutorials-old/whereToGoNext.md", + ], + "Reference" => "reference.md", ], - "Reference" => "reference.md", - ], -) + ) +end deploydocs( repo = "github.com/LCSB-BioCore/GigaSOM.jl.git", diff --git a/docs/src/assets/parallel.png b/docs/src/assets/parallel.png deleted file mode 100644 index c6f8a06a1f2be8e59f8264043fae8e0f2a5c0c9f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 195881 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%0A~qhmZCWp(u- zt~qpcb>PhTGq`r+3Z~|#=o~KD6h&c4A@cKS802I_AO5pN*s&nao;{C=$!YA~y&F|E z^djfgl=c*W=*+L)f`#C#tLKNCl0CWEefPp_!_> zIuao;YCG~7sBskjZ*D}eUa628KNW6S#BVA4_OQa(gj?&~-xo*{R0X|4ZsKVMlItg= zOCsb;8NbVkB-*cC!f$^1Ur}Ejrx8(({M>nDVUA?btKu2vz_>nMd9aSW)=x%=rdyNb z$wVN5vH2k0cz+b7l}GTvBY%dPidw1}84+Ij?9=3c+eU1}LtnGkz9~j4lR0S?Z+ew_ zL~>v{%D{`mXEGw3%<-P4fV~*fLCdBmX^bS?{wk7L<>3ci>%PjjJa~O)-_CH<0Kq^$ zzr6(rVJRW2^-A#PsZ*7Uy~3HAiC0&vr+1rprhslTn09e_S$dw^_xbI)pI-meSS&-P zn4cj*xaqlw;PB!dS{^#LJY5FvNbut-_}Bi?C!)aRw^~l>`5l3h`{6TSZMg!oG<}-e z|4+kBq+J85>Sv*!n)bJ#_Xh`SHX>;_v!`#|nTc?z#UT7>PlfY?$RZgh`RH53z{ee}A^Sw=efHb9vXe>iG5R&mP1 z2i4_fNV}tNXNW(2<)d@GnQ{L7d7Hx6zI{82ii#|rQuw^AqYN{DF=OvQFJ5@{1-$ge z3m6#hM|D#bHtpVwEezZCZ9(If22?dxqP(^YrPakKsbVNDLswrnB8ezA@7#p?=2{e2 z7BLi~pfn#f4YjDKtwPhrMr_*JjCJeR(HJSf@Z<g4x^VO~5`*6!*KBH_O{5(Hht{?ue zu-dxnM|{0OBf@Ty7c%G?XeVa!>de#xE?qoFV|2{+KAM<_U~o8!{^2D0hLNP_pZQJS2T81HF%c8P{Ta*0L^ouhax=EbKGAX#MJp#Dy~L0jH7xs!STE9)KX1_>*Cgaa){=Bv0e z5QL|Ql?uK>^ua&(>+xm5&9!!rn)}UkNx{jxFGlij>8Mjj9`{^EmwztMwGYZd!+0wh ztbj*1`+sX#o8k`!uCg`&Ai=lk3_XagA0NJDqJzl1XeuTTWrDNOrOcaLZ z7{e(Lr#V*nz`mbBNU)REE}3!TqY#BX4{yA2o`%FY9(h*Z&Zt2qjRm9VAP_nV%BSL7?8zO1R1X7uwHX`nPO@uEqIt~mB z;1|F61u7~k@Z^(E+Gn-}#(!cw#iI=B`xeo71PhS`y!hIS_~^t3ShukOo3=M2l%I(} z7BR}CvZNs>(+Z~A~f)nM!wOgkn;dF?&E{2wQH9@_(;TOfOv2% zthSf~KaMrYEmEx2b!%g+%`X*hRsLG|tn|MRkV)+@)mHNkA5BMUeZvyD2Q8z& zw+lc0{(r}bk6uS)ag2@yjbHUU%X+4il$t*;#HYrzC@D$K6g zyarcDDbHw1hV}2Q#YNAJpHpCM9C_9TYtwo(Ol@zY@f!H_o!>JEJqFdnDCKpwhCO*v zo4+LG^-~FGO~NN9UZoN7Lmb=|z_yLq$jX>fLurpTj@ug`jT;)M zP)Rej0Lw_Q?J@!(5yI%N@})RwS7Ks(3g=!r$94HF<~Olr%NC0vSxfEbzMv7Y)_~BQj62t&4;5qzf(0cgfI6zUaEw+LpXp;;v-7j| z-uT$$m?5ko({OYV^U(!UIwG`>u=27%vgxaxIkb$53QJH$ucoxL6nVJ?D9kUgUGTIH zM*HQ5=yliv6b<7toyu3xUKH`Am>CB?2@>>7#>U1lH#cYH`9_OLJ+jF28t0k|(T4nI* z7{70zFtB$e=NQf_Mg;jlLTgW0U&;#UmB8tbU%@9Iejod{2C#!hL}u=|%fkYBN9j~c zW=7HFnFLr*c|*KTrLmkW#G7xNN8622JoL;~Y+PSWZ$VQX5jWbvcCq+$eV5dJR3uUc zHac#PgEU665RCwDyf%pO$sXMIz*aP`FQUR&w9db*Kqjo zVVe?>3|V^8!A~c7*Jw?YPQ}>t82;-&{%3r2<|EY85ZJzV3o2_Xkyn_DjF5I3b!q%p zFx5_y*<^SLFTeUaCMKqE|AY6TuA!PT%D5T&1U=QvN5=a=jfb z?_5D+!v_2>fB(N?=e9j;vJ9uRNjrVKO(32(Bf{v$K<3aWmBZ;~n*3qF;c5DS!N6xW zgHPkQssfxIG0@TYe>H^+G5@($UDw{xKt?KIB@cq0tvomIa=YoCgHPZ2{eq9RG^uEz zpKEW*3p;Ehi5^jRT)lP?KmExMQCD4q2OcZ&*M;A_{9`&1llY?}%P20<+g+Lhb-G$3AgQmhTT{x2;H5?y^d;ft^lVJd z&f_<4PSM#d#?gnriH4>fG;G{w#iiH9!`9A}@zr|zxC$KKBq*=`N9>+{`e|DPE%4>t zZ_h7mM69B*YCs6IFTIcu$YYXXh_TZipBzX3P`@dpzJXqJ_jX~1h6J4e1ha#-`HVdM z?0mhu?G!?QmX|;spS%Gb7w5y4eZ659=T5$XQy=^@ z4s1=wj?KBq%o=1R9RC_nA@~YbCW9^LmFG%2$g8jou9gezE1qpFBY5OF8WGLa2&U19 zSkk%&mPJ01ECjT`kLU7yd5~#q`I`5V%w~s{hF4!3!pKM)9(rUqHf${A{aMNa(V%f) zLQG_wVWd2HP&@l=&-5Rpa09x@!DIjpsl+N%b7{6+favXcWuSa{oAp1*A`nWwQ0vjtl!pz zhK=w45Q)}pW^50i7_IDhp5K6v*- zw6?a;LR&^ZJT%XXX=l=6sfu0Fg<7y;Eua{9dDo@hVeJ?ruqd^jziG ztrSpv@09SV$G>;*1zvS<+^G}QM18O5Ba2?oh3im%JNmmXptd}K@}eLzLC;_Fh!jqJ z6iXlLvTJ3aBWXbLF+Z6U%t!kIq~)NkZG?t$7#lX1qqtDtg`gLr;Y_d1wJ1-R86H%g zDqF+xb~JNBuM?)@M%w}w!?S2?szPB=7TaGnU_1?xs-xEkhI#~^3orXJD31X?PZzE{ zhU2+t?O8xPy&Mf2_Mou1%*wY{6jdPWuX^~cYCVjCz-gKk_Bomp~^mW?D3}Jd+C){3&-SPEkqY_UBkpB1vqf`0hCvjv+7G|Z)?ZJ^A~XW;uW;F zcA~qz8+|>480s6u$j}G|dIr$d*~{}W=9`B3xp_JX{nQCJaHFLSSFc{jOErNINw}DTm zuL_twgOgt|y~8J@+Sh6HkRLM%fI8#NUor-JB7XDYZ*aZsDvg0U zJn{9%@Wj_1$KE4*P}f|8vf5JQmggXlmxhddrpt)Y$e<<5p!0Z;#L6KNvZ;(RqEw6# zEN3T?RTxBmMLy~`)?(kid+7l_f@i+<6z+NOZcNNh;N@3ez-zC)YEvbNlp!{s0RFWE zC@&}1cFz;DYc!;xp#fXBZngPa%?oKYxh%4yM~~V9v6ClH+9!=|ghV}x2R@OVz&Y^C z^UJ;+=$V4%dX15JF7Hvh_DKmlPtplwW>%Jc=}=oXXN0nBp18DtXlQ8K zNE`8JWv>*0aMhN`X=GeS0k)M{v&>5i%!C8;_vy1D`~?5rzkawCjU|A@-z))v^OoE- zsb+1%kk0cYJMLsA1zI7S=b9E%(B?1N*MLIFqrMpFybHZLNzrDfo@6^n9u%|}LApjU z^qL@pya*>NY)A9@+3DM`CxhbA0I;+?PwL$JE`}CI*}1%sWtOjRf+T~)lQ`*|Mtfk1 zxFx)6!g9Ji4UJfkClt)(@un3mhQo0+5N^+!p9_3`5m%g_Z&HFBakL% zDfkKeI;G0Mt;uzBy3c}CI(-$O{C!W~2i;R0^s|-tymkvk)p5vBaCKDsSiEKGWI8LR zBY19${G>ZHl12*1;FHd=kvWDK^Ux|+y%ivRqg_93=vSb$KK)GgeB;9DMuDRIYXWC1 z_(8ftwO$32V-MV?z}30C_;y@j7hSo2(mnU|kw9f6*$yHd4TdFTTSzi2)1_HX%i_7V z#pXTgpry1BuY)X4`%oy*VAD0j63?~H$zK32(8xzpH1@7nPXw#+3~qY%+d*~t0>H+< zsX&Rnl%ky?!454*29n}njkTQLvOoBO?$;1Ysn!TUN$0 zqIwlhe5cV}D!w?au>>1tnH1J@1!R&cL3%7f-Af{t7Gt^y0$BkRR}|ymJqPf$KY0#i zmF0N;rRVX{#~)#Se%2J2Mj(7a5{Q9i4XYDfm|HXhL0|8a;h^ai-y#=C!Dtv(YcMWc zxPbote!DLL6DSWScsI9%6XsOb`)pWIuwFjC5G#*3m8uMfxV!@jb(~B*F+GPqdSLC{ zy*PgAG~RyyBfR|jTlmdOuj2WaU&AYJyv;T^jtf_=qjzuwb7AfB7q!n{Yve-n#9DM0 z%+|7Zho|c8*Tt_-x^;xC&)VPbUbWvVr1B?8MNnHZ*z28M(AWTvwwHwMCa0s~D8ACU z&e){O3=SE0zQIAI%Xp>`_)Oii0vpE4UMb&CuAre|Q#_=b7f4!7`Cdk_ z)*DHtG>zf&^IBQawa9EH7&{}aU{5T?Y==rs18JXz+I2P9dvG5fd-@6d>0f>afAjDE z8h`Wm{}zAoH-C<2{`hHZ-Lc)KT{dpsV9tO>6aV;+{}_Mymw%4G`m4Xd|NI~SJ^toz z{~G`1JKw=Wk358?=0=3Fq=e};OUG!vh>^&niNUQ1!*)=c++2k_f>b&C(!rByALKL{ zg+0BcVilx!WE4o{*=vN1qc;Nd<=9AK84J`KlQZ)e8J|G!z#uw$dePb2hxV>+-01Aa z(D10~l|?!!N@pLTlSHUI85!g`Q>Scq(=T>GP@9X+D)K8L!RG_N%~Qdr;mrt2avf+) z(&-b$)ZG=Tdr7oD!Vfgb7-yAt6jw9|%7_qsNHg2cUUc)ykTKxPlR)QA_QkVG!=jj> z3=x;N8)aC9lzMr_kFdT8jOTxMSd;orV67n`V7AoD85x@UNn&)Iom+dmEo96s%teyM zfHwGv#-n!hhvY>JF|~qu#Z)?CH2<0A@#R_Teu5q860K+xJhNJ;q62VJK=UkmplGVXTQWh{9oV2fBrxJfpLF?500Ne zckdu(7xjt}JqTJ8QReAaC%|^`8P?&!cHntIMY$G0*XxJ_VB3Q~GKLw&RR25|8vNH~-Vv&w^ zaVZ#O(2;mESUkPv9B@iag}S;qe)7Gin^@5rk2soP_2q!izv3hDzxIjH^q)TkcoU?= z6}iNRN}onOqE~6tgY;ShtO4a+I|j6pkt(Wb^mD+YlS^D3<6rBwLc zIbZeigMXb0sn2f}K0kaKZ{_2;0Bjp4)6RF%lpATCL64VuVRkM|2W1rJFI~k;ufK_Z z{?SkHpa0<>@xTAqf5iXsU;aD(zyIYQ@W20`{~iDB|N0(&@ROh6S1-JT6Q|Fjt-TB5 z)3c_h)ER2JPIigr@P&`PDdY;gsZP>;cV5r00EEU0Dn1?E4YR`~Gn6O4A9S)U(@8XS zbakL}MG{{*-m)|+Y{T|2-O8IL>(VijWeS62Sqm&Z=RkVKo=I;|);02=`_dVvA5^~b z_H>|~ehQzPVfD%F~_DWzmc%ggeCxvPT>(!vkWd+6xy#OrUqhVvKC zp{K7K3kwU@accp`Qfvu{C>3I434x>rfrAL9h3pturWfu9GNbmmPhT=Wh3!Xj#p#jL zO4i#Iv-HS^>1khWxs10zcnhDLKaJ_xDYMRmukZh(}Du`>|#`l&!lZm_T4=72O` ztBr8vHOa`4VpDvj7rr{z(Kl+8)<0UFJSwiveZxqgbON7tsi1obW|T;gDvl{yXHDug zRvqUSq8OW+!3W1qP-uUS?|%PBc=yUcf*6)Awj>e1O)DF3c^mLsS{0ufL&* zP*M5z`kRNd^I{yB45fsBH6Y7Mky7&NuaORuQ75a$Q?5wn?}N@(uf7cM>**U2+Q7zN zJS|u;B>W>m_w@Fq3_Y#sQMv@_q0Of>C8-6`S|_BBZ^>bxBCudX$;>u0FB7@>xtN-n z#I@FIn3+UlUHCFUYFao*_o=E{bET}Qj;W*cJc!ZB8JxX%8PC7^I{xVgKfyo#;D>nQ z-H*^SGEM_KM8mBbn|AKP?)?X`W6wTR)~>_E%mPlHxqu)2_?P(J4}W4Kp=U2##xT=F zH1$fIqWugsZK4s?l{z!%qgJe;6wH@a&pQQ`{wWMrbc)w@?vMhSqGVs8eu?sBkdo0& zBLXze6#Va3C`+f86h|3Lhj{$$>jYyuo|A|DMb&1&=*)2O?@QKot-M^{;l;B5E3gvB zJH10lsnzEImFSh~H@0JN)kKQQ%a)fKdQ=BmP?V1yyS5|0FyD5pd-KgV?Mr^|zV$9H zoW6*TmQIZJjbVCZ2Gc{cm>-|R;^YFtQ;T-KFfniEk?Anc7cnzFi}9fe40I2ov$Y$S z&t1l`_m1IhdgO1v_AZW{JdSB9k1bm^W7F2nrnDp+!bSVw3tzu86eFvBBJ{{LVzCrY z*i?q5G}P%!i75yYWPDA}XjoSBy8h~N84y}~px0saO!1Vje-x-pQ;3q(TY#gX3+lur zXria`j_Q-n?8qmkXK?Y-W&G?H|AJRudlNGYVcd80Vf^J^|9gD%PyQ794&9BKhGvvj z)}yej3fToE^s@4)Sc_3z-;Dit-;XDs`v$&4L*tLX{pTpFs>9pweQ3tW`^P>;XU_m; z!|u%u;UOc*$5+<^wXz@n?**&uuBwP%EwHW{I+XBhF4vA0>1Oe8_2oTNCVJ+Ac7Ie8 zYfXgC<#CzO#XRNFnZodTIz96hh#%~p=I$(S%`@wmLEMzL^5k<%pHilWyR?Tkt&uS; z#i5{cS{0UuWKoMQm9G?*$tHvNT{)$KT35d5P0H>|0H3FQqgyiL8xaDZuU7cW?_haQ z=VqO~NTiOA6_iZ+{Fw@7U{Jm?g6SwOD@I9qDeoq2-9u4n5n_5HiRq{v#xvje7VbUz7!KU? z0FFHPD4uxsoA}P3{|)|z&db-n`6q0HYU--1`1!y520vrai0RDi0^5NkrlTTXT_0C- zUv75%ZMyhNr0<=A$)G@}C>Y6g&jsH9sF-OPfo++&!&v zQX2bnLHY4$@x1&MN25WeC!|~KftRn{r?u^^%aXiX@XKI4SDu3MC4X57YeD&?!mZ-m z9@eH#0aZoe_8>*%hg2{oXPtzwrZ6PD!ORRaZ*H~?W}bcaS$kbX@6(Qtj^omWOL*sv zck#;eui}+ozlP_3_5xmh{x!Vv!s~eLr8n^wjRJ)?UU?g@zW4^8y@D6X+|U2Z3wYsQ zUdB7GzJs$T&tb4<2wCa5*x0ZM4?p%Wp8Gn>Ij|2QmMtYD#kC@&6dHd%P9qHhEt2f) z?6&m|{tjJIN>U>Jl#2|A-d=44G;Q$|N5;doYuE7p`|so3ci*+6rf;M)Wwa;{m8)?5M!|yQBQ1K=Yr<|}6N$|%Gf4pnF!M{(0yLGWd=-b+3`s9o98ISQ7a9_lPVv*U zI8q}|Qb-ECW;rU;1$L~$XUXBFSqpb`=T;ZRQCy}?x5b#knSt%-%dE$8EJvUVc`cuz z7CtBVJR|;>?1+ygGcv&S?6yD>t6#CXUX4PS+`0}%L2pZFb-xyGn_?ws2U}fh6Dhql zrS(hYl@(}csJBr?mFe^I3SKnxq#Fd1HT!y~-tk|-&FKq)Zmrz@EVv!-tpVlh)5b4D zka~VA_)NOp0zC4hz^V*LA4xB%UG%!*(9jT0o;-yg|LhkyfBCA7E<8&e^Bj$WZ9DcL zzqk~EtQ_`PLBvRF=@NR1G|VzcNwx{krCXXeZ^PqHK80_7>pM)h8-4wQ_{|G1;?>vR z#K7PX79wu<)d1uB_^q_b8hZR8K&8{Kzqu*xg0%B=h=fQ6>8iG4AE8CfD;44s3+|T7 z^Jeg)>Znmp+xvw)?@veQu{j17sZk5Al!%E=-A6Zw)n}FA11Y-tYNNMrr~<$AuEuH?2IhrW#^&1q#PR>H{q^b`|;5INAcw2 zPvL9Nd=uYz?vL@*6Hnvl0}o--rVYr=%`qWS5QD0G9rZb2-4lpm*$n0(X^n#xO3FBp z0`fb1#np4g)$z)eE4IEuhLF}r=ylI`-gyVZ!^1WTp;49}{pd&bI-6gnFZjws;9su= zKd#YDi=oIAnn%8T?K*z-t6$-hPtKsJc>|t)<{8|z?*Q@(iy4P?W47`j1EEawe$y!A z=@d|{qSlIP1)VQbiE90WuJzhaC@UN58k+FPqmN_%frA(r8Zjf~`t|F!-s4tyr{eC~ zSR*?r!*2iDz<4v2)ivrso=}jHMd7#62hznKO)z1l5n)I3@^zG;GA-jspxp?8OJ_$K z0n7FlhMRU;p_O_=844cR+=40M!~4tX!IUS@P4>NO=<9uYL&5T-0U#VTT4BZk^X1Xu zC3gUTz)-@>~zkD7r zI5>!7$ByCUmtQu$^7s?a;9=^8^2#bYV%gL!Aw)@Q84FrysYR1=Tx1OELsRM0(P=>$ z1uTR79ip*OQB{K@_uY#psjrGl%5dt`DZKE)3%E|*I5j(CxM(Cz2As)#rJCN=qfTcT+f;`QRH0QhDOL5XLA@#=tz49QB8B2%+VhC$7SZ)&ewRx!6Scb-3>#+IoChR!66MLS# z3rC*32ao>IBY5cRNAd7C9VUeIYH8t_5gpb{^eaDg{S1)UX;Wsq@b6F3d${v2J}mic5=Z zDn=q>bFe&WRHdV<9TzWOKwW*Uy>6xz-MZGgi`{#7;n}Y}WA`s#x{m8tZ(x2l%*>_4 zy^ctXrnXNw%HAXwhKO}P#D=uf@N6Q6wzdv@|DS(^&YodB^!U>_{NTeVUDtqEb{4|q z*=SJpWb`2OmYGUH%!x*j8IK@CZ@Mf;$(RcCvY4lsOY18n_%SklGMI(zf@0it;BGu{ z?}HfV?8T3M^$T32Au%<#NL;iQMOaY)k})DpAL)2~#IrHGoXI@mm`|UhOw1$BU<*S< z2>uyabQFnrBh5bK6(;{h9o@bJkUsF0zecyD8?>XWKPo7lVI8@CL_|UStI+^^PJWVc zuq=a4hMGoNWH=C{e^cA#=xT_v|^Y^emIl&{$^+iOvB$_^mGdQ0|LMjGSWlnv90>vi;lUzXO8q?WB< zz;xU#^J%^f2+YrbvCY|)!pvq52 zka%k4e~5ahw%>{A6SsKKXs!N*R|>KljrJ@!FFD;#YPMSXH zO+!FB*~^y{VO-J3`O@Ml8(GwA;LKMGCDYOt`CK2{xT*=&(x&eN3kal7BSb9{U|$ko{$_yaO*E~mVqD>t zK|Ce*>gi%o-{6coIV7P(5FxEWWPNStP1f1HwZv2Yl>UxDiorJ;B<#L*uWMQJbBhb? zlzTBbHAAHwr4vj=K%}&oMq;43N$YQn)XYPRqG(E)T(NTgV$4vshR3pynUiS?qijAk zGi32>Wv@C-2`veud>vVIEoLH)&dl?I9(kRf7kot~Z@;6X!|v&=jKcgPPl$;dgA`Y1 zdlz1R^$kSBF+9YMe9x}EC}QWEpId;EqEb8NWaXl~w8H8)zsQCsP8l+uWWk%V677h# zw%|2zjcz~z6(mR_y<4qUI6J#~@aEg^U|?|AhJzz9_bfrKSJegDFv>c4k%Gc*Q#EV@ z^9VfPQw!E+EGDeIl?|k*$CT=T$JsN( zyr<_Aj5lHVbPuNE!qpj^JTrpRjCb#=cTGl~le8AoxMCy&Xvd;N{X zF!|45IYHv2IN}4zpKz5@mmaM?d)~-hTI8%*?va3}{+PI}%HW8BjJIs2;9wVRSf0a;SF$G~z7JWQg_99uwJYmpm58=Za$(T5TL& zEKj3Uk^<&J)I@7HDq==64LivmGa(s;J1OXz<9FM!(y`FZ;0WeM9dtAXC5V9rhli** z&*IpLW5~+OLK>-mspS%KOL9?CU5e!pQPd86M9{`MsMPG8dljUHV%;>{rY3hE4RK{j z(A2P6QJhXL5)J|@o9PV$9hHX&$%-z_vSYZ?YTIA$-mwQoxy38EE28jEvV$BP7_vS6 z7Z<{K=%I&D->`0FIzv+z7cXA4O>C}Sy^6cV^j~ssWKo; zh=r$83M6#w%#CRUdcr((@U8?Z!&BvU(|FDvsyr9V@^hJbaUB+-x6zeH#njm{y{Kg~ zXn0K`3=j0-)JN~2<g8_c)5*|*3(V^>(_g;afh$a}?}nl4OzE=WBWI$EDFN3Z;9>s5r=nU+-*BeyV*+#*lN;U{ITwGlETq}Viip!FZ= zG$!U3W@!K~n%wDQUXC@%zv>Oxp=8)6zA2NGLrsB*!^|il9#nIt8Kh z5WR3cV?Hd}j2P0&RFBWEOs8D|m)Ma=XRQT|MjDxwUMxwOG5I%LB$>=WPfrhi`SV}k zJ@Vol@y{#BwGS}`h_k=LtTuI0=dN_3nHdhkgXiiv^%jdpEu3D|pVOTV3{eX*z^CZs zz5n4c^6^E4Wn2YTosU^>zAH86s*&+?9TTe3y+Ql}>q^0Jf2 z3aR5DK(*T$GiUSv=|E3|7eAf-*GqPpxKERAH_hm64pI zPSW%g<(l`GVrpBJMZL;ot_?(AmxnH;7u>F*(go@WnZ}$Qwm*$EiHCdrTYZi5xXB*8 zuiQijeYw-=QstvIkr8&~%4JklR@u&u+1Z&)&gUAjO`(-L6PM{=$k@R~Vt5gc&g0^_ zkC<)&TbrZE)tiATiM%S5Y&z4{Ny*61pwBIg^qgiMjeuo_B0pE+4$B-aXXd$5t2(1tJ=kNaz6VuZ;cxXQgO7iKgokn(HHcD$~L}aK{#B6LN z>qJ~Wu7Zx)rPzhA1e!6~h)yU>XskDL9fUJLbRuR~It3DpjG?&U*)UozJ0oK6_I)UyPFf}uW>n+z2jzm#jUS%GXtc8%anDrE+6pf9^ zFdr#9m0+|}8T?1Hk3vifob4z3lhTfstJm<+haXwGhaY_md-v=`c5XgeuC*|B8mgI&t+f{?rLCS37p4Qydp`<$!Hw0__COvNI4}G`yy? zqL2Q&d|a74v-;`23>9&?&KcLw$VKFB`BiDwnTk);2I@qH`nu8Ic@g#1LG0XCP9w6C z9Y{Hv>Ps2QcwWh4g%`@OzOjnODm1Pu$NGjcG&R;>U2Pp}5I}Ayhu&5Vwry=-yb|}k zo_R8EL!;7av7hp+K?Cz>XsAFV%V})lk@wcsQdpOYP*#|ZJ=>eGV{8qP@CWQB#IMrp6E`^Q?&S zl>=Z#gy!Ta(`sKigG;d(4ZTY==sN6M8~HRoZ1|QB-R_J!C!aXhwLH{O=vIQA>Ed7n z(MSy2Ht$5GMiynHh*xNAnBL>L%8^&5Ybj)nI?6E6-Z!MK%XlR??(~1s0hW%LsM@NN zV2sYkW#T;^rU5xVgua0u>Wl=6iuJmAChP0?+)XbMl!xlVV^Za+U74W_1jADmB8XO| z13Vh*xi*D5e)1Hzp2b_%R%&Go}UV+T2fivb?mEdYAIa?4*aZC|QPW z(nco4n{vXKzvxbyWMq)yG%mHxb9y>GZO~!WRI0^Szd}$ypvG|*-k79#z)b{!ktaPNx3s9e`UZR0e62H0a=7j{~QXfvSlf8ERIx4Cv(A>P> z_*!11Yr6JDO5(=#R!mJ#(THd^$JIS${fL9FBJS9TkW%m$CFwjtBSIG0TW`OG?|%2Y zxO(L}?mDm^k39MiE1bmXi>J+)C@e1o^Z^?Rn^RP*fK@^zG4Nb>oq(~yC@k_ed~gbb z_jpU`b#1A|c?|aY74N&}X*LqkdbJg`<#pJ-b2kbL3Qd8k5ru#MKtJAn{~eq;d&WHZ z;*ugVd6ZsYJG#0$?fV;%NSKOmD;fVGJn+E%sHv%Do!O`?L>p@9e42zlfBu|>b?fSI zZX*LGE)o%*Do8Y)Q6b?7k|CF3akO5$ zh7UjdkcuN4_dW0c8k-xbP(vs!DZ|9%G-=U61}Z~Ec_}hO6lzm^Y6BJ|rc^t6ZE2O# zsaMxk@Z+d-rKPg4g@0;0uMKUOPtOS!*MUDo=<;8^?>I#~pbMT0FT1aQk}{W_Zmh!Q4Yi0Z&N9a^9)DsJ4j*XbbH?9X#?Gh$TbXw2)^coPnk{y(5?eR% z9?!Shy$WpJRDq2f$`P4~)8LKZq5C)E$iaH-qG7X*qXpgIBS+g*`1~Ug0#m*O z=?IPD=@sZyBf<}Y)O7JMJBiGoc~zHaL})EjIgN<4AmeK!L4Qiij$isfdLv0=TGNi^ zsV8E%a`g&3;t|_eN}X_kUbXl@-|>;LZMcz#B@4#SOfS$#+f>IC=I0l%X~Py27Z#IJ z%2)B!wK%>KNOnE7YsN!tDatZ8cLP~0f%Iy^E=!=WF= z^#%5MfYGs0w6?X{RHo!yoq2Xvw&_G)_mY#14w4~_gvdw{NIrE}p#MIt^;8~pSKk0$ zf8$ML=M~`4Jx8#fj!`bphlWSc*WZV{JQ@-O`DR?1<3pK|jugGEJj+kUg)4&*c&^-~ zcc>Rkhf{VLx;i>>{Md2CS?&W5K7=jXwxhhPg8DOvRvMQ}G#IPN6Z!dcRMcs*gSDwy z=52<9YM^3NgcZkHGJl#W^pqDdHK|bvI(5vCsIe1t>w4sPSbQE0H=Y}Y)CH=K{86c9 zu|kx(aCCUs+AJ+2+v3_DLVRetj%^$wuSnm!sWx|ilstX;@->vQv);I2D~%@_G(oQ{ z(kdxEt3HS2o0G$%9AvSZvej2%H8MIsJ&enjjw6^BNArdlva)ljtGsf=Vd_U%uC2)+ z3ySkpH?_ZvEp_|}R(?Uee~oRFk%P@!_o0M%r7@l8;udepcpz^YO_@J&G1^E^GLrCJ zwkebVu3u}##P}qdH*P>VjU);YdCVDB*3`u8wnhZAw{Saf&|i--#iH!C8CZ|hDb^%SPrej zxoI*9D;x^Ad0mmsIv)#`)e631ZrPH0wlKF~BN0{Q)!4m#4+`=NoT8ynNPx94QqvIo z_U)s=^9b&}?+AA9+C#i|VJns0UHk9Cp~Hu;XYXFr)Yi}&XNS%DX3{H=qEMl;bF=vP z#K$;!>J;+w^J&rV#}GZPIGM7ju!u~hd8uWa`VhT*%pFhhijmO`HuKzC;cW~V2yZF>=_%R|U!+)&yaJ+eh)GF~XK$aM3} zBZ|z77!A2F?}zPq2JZy}G2$3TZ;v{$ei{g6sHzAcD>zR)=lMKBLr$F_(+G#)g5l)w zBrbs{%ZN~kO+qhewDpbBJITR@&3jN%#7zmpi0};v zf%yLN<;$ig^bH!lf@y8FCauya1gWefGmDW0TyMQXMLmMz(qa@A6_PKRFO!)wA=IfS zd;@?Fc}@=GrM^D67+FMJV?ByWirK-WlU|Yt>Icao`CD%&Sz6OI8c|J}@i2US1X-Cm zG~RZhth5|ip)As$DH&HIS3)}+jiRfk6K&n?=o#q7(8M4HNBht}I*3t*A%?-xVGNG( zxF+-t^^CVbcaw)mC9{VIJqspGS93x8WV4 zKFQ9>p+3=6CN-#r*I5@bxY0$U6>_F{dJ}zht`?C;8m9OD?gt;Dw|@`^4&9B~b&aH5 z21?4x?VCJ(y}cOdA7EKQcJ^g9#cAKhVB6T57RjXeNBODPHSH(|Pi+tjFWMK8Km6bW zdfH*!doOiN^CmM^)O7jerNzYsbkkugE-pY#RTcT$z4mEY8qVSqo?6Db(mpmc*rExH z42|Q14?e=_)2FB#^H5P)O_Hc1lriV=h?J0OZpuiyK*oVaGTfqiebH5p#^6vF-g@gL zI%pSZSmmLpDBrdRNYdcb-XrSDWi*Huru$hz86#t(Q@C>F2IW5=wbfK0bdF}`7Hw0& zx%n7F+>Y~$i-@MxfsFlkY> ziFB&&+yN+=A7l;d^{vkC4*SBThNsGks<40m9_&A~7t`S>{KNPD8;a_RuyOAuWaI~_ z*yNeB9oQ)D-B+Q+4wXmCH$%fTx|5FCsaae+e+i9^jkXR!3`#%|^RjH=mG{8o9~Yl>RQ>|q)((?exGQf_MnuMPrlG?u%n(;TCqMcPPJQ@4 zaA;Q=wlxP)kQK8u=D`qb-qgU+(J#xMvTfs39Lc48y!Pr@wA~oRGtX_u`t?=hwJ5XD zw%Edtp!_TdF|A9XQZ&>>3>tsS8SI>rc=@#v8si;!?9l^gVuvE*L95}7H)%+-0nOGn z&qCgw@)V95&Lv*yn4&_dz>DvVk{7n)kthEi4fQ*DpPd2CD5IJgt~$|hsc0$CGZ_(U z;q!kAT+amJH}UOHfBI9Ck#BtC8}{0z;@!kY1e0UsxfHO?pkm1g-ahs=!yCxR4WMyz zy?q-em=m(SXH>T7020HW!j$o`9F616x8K6Z_$X!LZW~=wTgy}TK|E?Gvg+zs)hUgQ zjoCVs@w4ODzwZDZd-yTb)zlk4wuVT}q%uhF?%pmu|H=#K8SEhiW0W20FAAJS8iF#E zKLgZ`(jk&V(MKdyz5NB98;@+k`i4dtFF8nrKjp* zSLhb_(Ew0B8X0wDSDQLqwOCN~Q@)yh(MS7Q=on}dC{2;>KXeHBGB9OOvE_w_Mqo9) zD?IPM`yOlSYx+h6~F^m}w%G-w+Mt58itpZ@=?8@krvY z|Mu^2FF;^5&& z@YrLIVcWLt%!7^xy?h-L;+e6LSJQ~L=dv+8u`$!wMY@=aV!?u+JtF|LA1T==B2^R8bQtK;R zd&>1Tpd5vg^Q@^UaaJ%KCNE+eNfmkOW+X=c?q)^MJte!RzXQ($5)z52qR zyo%|mX`DQ95+&?RtI6~^w3PKB9!6Bn^~ZGj#}vBeKQXmb>EcY-rZZ~FYO!a#-lovJ zFyoQ=6;B|mQj8%bq`FASYUeuNu#lmlxDqJUSD<(Ttpm}X3R+iTMA+VLZ|k&>&VT?q>$9|NDoNuWb_v13i^pAYCl=2>g0K8&xnzA zoXpJP>)gK9ki;l~Wb>uL*=AK}HoE(Os<9$GsiR6o^dYTl2c!YV3HDPyH~#1S2Y z0WZ8DT8k%jFBR^5VWs#;N>_`MWprygLwm9K&r`v_b{J^5Ih}D!OiucH`q6QN?KCiA z)0ENqh;5ahh$IkSjGA28oH(l&pHHB#tKUATS6fqOZ%-|2BeodTCylgtWC>yFhQ(=m zWm602>m0!KORea-(TmBEDID2#kA2;Js&7lx^|BhsQ8HHh~>` z_M*J1js}@VHE6^dYXwkVR$}d}(H!YEjdT`L|Jnx$70ELmm?{6c4%)cLyN-p!=<4jk z`|rO;`C7)2d+*0)c4!&FOy)0RMI+hjh_!Yifce>J^!N9oflgvsSt;v7JxqL@+&Eq| zo}{cMV^MUsx8TL!{1}%meu%=NW$L75)Yeto6zt++gkHDvycg%f=IJk*w@)v8ejeeu z1@gtLE$WQZE04z(Fg-Dd(V;Gkjdan;(}x%*$?wB-*m6-?RLQbs_yld_%0|&mhKUoM z5=|?&-?)scmp?>j*Kzdpe2m^3SJ2ab20a~TX#k$(@eF!--q)tD#U2u_llnrluW_zOk2p<-~ayi?TdL&J@phSN%)|w z7Fu{+eeL*%AN+TeH&mc$=XwNkHGe}(S=OdXCzCnmbC$C5)B+UUlinQcAH-`fzlOUH z-D8U$?c+Vvb$T@~K_#kTN{cJuCQdrnu%EuWG1fPZmw)yu_HEgZzx?)Jp}vBhC9O)o z+ENB8PfAYSv39+)Z|AGyBPcJ2+s&Xf%0rvi==D$q85I7r{7}9L_weWtO3KS@#d9XT zd>JB2qoeArqdFD|u!_;P*1!DaFX`RP-6%4i#UG#IC67yasPu4p?UL0 zA?;{Dvt340l+~A~kiqtdhUf6kYcH@Z7V)ibK1a`Zohf;N8~w3U0^3dtm6*=v4QVWD zv6u3-1)!p;I&QIy(VxO-Kw4TJGB>0$7+?2{AEZpE#C0ttX*6SGrMH*314+tRROV-~VQla^M7jpgGI zGz@*bZC;hiL_S|~Ig546$Lp`2$BotzJpT19^tx)CzeQu?HX0Esjd-X!))6>^Zxy8ARnOFWg3mV+;me^w?Ju79MEdecGm3rFWMNKdb&?J6#}Tt-{R4b05XAVwa@q2Vl}#K~Bu z$z6IZhzquV%M7+|--2p(Fru)Gm;{XttucsEh9=mNYG_%nXKR{p^QJA>zI`V))~rWS zeu3!&^JGa|@t-!{g0x#VFQhwkQs@NpWNuF z84)H6ER#><9CY^$;-Bd`=j0dRF!e=lVF`70CLKYi^Mn^A*xIbCOGEr`+o-~$k3Meg zCi;rk#LL3f^e1(bBL7KdDKQAG?L*qx+6EK-BoAik+u{=XX{m* zI`%FO?A?XOAAN|l&lk%Prx^8@c+&BO^jLrA4H^*NMNju>?AcR~y!;g z+|NzmotJ)t3+LZKaY>B4&34ru5iHVpnQ5H+FSA>rLE_~}d z{{fqK>|q?HQ=iNB(@%Zo7aoa_0EF3*ojrHf6ofvL?Ot=qA;YLUNMK@S3g<4JM<_Rh z@|sFyWYH@(Wvm*p8eU%NxMk%nF=*IDYazr25q5m-^gwEAt5LxYQhVvET-8^@ceYZS zX-)Zf5LgN#Gc61A6AQR@p#^nib=bRoFG>n&O(`LnNS3ZZM=56ASA*E%OqOf+L?}Dg z(FN1M5(M6pQpwKBL4IDoy`fQ3Oi!b<3^|$EX6e54@{8!9C0|)pX*+0IhpGjT1bH|1 zrA*>)`$#ZM%}kq@kXKMf?{NdI&K#o+*@H~tE9Yff;G?%&G_s*p_nJ4=E^z6g43t%r zAvc&~L!VCG%-Gcjm=H0V-XqB>(m`Z6x+^i6}#@yu#2;#jyC#24Ct%SIANFVVvklJerYx>RRuE%2F45-037oJfIm z>oL^Uj3`{i157vAcLPH`=TTF!jEd3#4Gm2Qi3&_FRO}~ocu)E3LeKpBLL6D>Y8%4T z#5^`^DWzAW^*5T%pg~1UWD(0qbB2k_Q+mL-kaZJ3S>37J+S}&YxlPdvsHPVkvc*;z z5`M#lw9t|HQQUdY;h4^LGkk;#^NUg`-RN0hcT$Pw4g2i1J-6^!^b|PyDo5q%FwwRC z>pB(g?ClIw{0wSmt%=ZvQ*s3SkqE)s^a71E8lNz&KDZYQWTKEAeRD%I_U_n=O--9o zUQ}V*d`D&@m>il!Uq>H3t6mKE4r8QijNWQ5?@wT1W&y)J!}ffjbAX*tKPLLeupCaJ zEU%nK<1RdU?_+rCp=WS-?_o4nHz6-4m(A;xrHppd5z;fliRfy(qM?Z)T<^Mu+~O=0 zRTLnZK~4>@jrA#U#xaADL9esxzCM!Xhh@9RbFrV|u_@G<*v^GIvQv{@uu&#GXL;uE zgwk!-Uws>7baV{Yu3bTNF>HG?xq*_3 zO4K#1M@C5d2$1hYb0uJ0y&^8%mYbVrG|&k1#Kfe14nRhPjnq=UO%E79xi%JmO-@eW z*sz7vs?Wa+DP$u`o4E# z*ia;qbee|rlhmV=BR%NuIEm)^Bp!OO1_$mc#Qx3Y*tM+$JGU2O=eA-y-?g>W3uV}~ zy$m~ezIAI6jn-0Z*;lTvT_m7ZlPJ)A1Q~%p5B<@F(Zfl zsC%aGnpm97Eb4phH?fSquCr{{3EX|S0!I#)W6$;?>}Gkpx0hhgHkR+>v6XmjDnUu@n z!-ws|RpbvdfYss8vxB>Q{W86VgfYK%t`rth(8k&F(8#n}>}X7H46J!5C%jtV(cX3g zH8r*NKCyf4NF9Xmpg0p3RVK&|$`=1H+stS-k`dz8JBEQ9gV7+d$PnzsaBDeCA)l#q%ZzCu(TwOP!sUidZ6T|8&6rd8Hd*_43|;jw=5 zOeOH5fjV$?`h&wGIC0_x>Y6s8zM+wfL#04`-RqPhoAQ^%A156&&$WcC>}(XUW1pUx zL3?M1#VN@vL{4^=TH1_&#rXxawYK7ulc$iG5wtZBJ9h7;vLV5Vj|>UDdZi9hbF549 z_Ek3)Dt)xbHtg|Ie|md-PPCydCkgIDAEJs*x86Gqkn1{Mo^feI>;kUIL6`Y=e# zr=A<5>RL)pd5I@;(AhqQsmT~NZYpIbS45ALyi7U}JNDfwk4L66V>Kx{IKESTq?Yu` z*-|16H`*83;m%@xV=a{l6-GL1B|Z_xGOi3%zOL0l=n3!BK+>=)(!+=@W#L9Qjfk{z ztlvl@f}O4{N>rl?R0qpzl@0=T<;7WnFTSwy5)D|51ZX{kwu@K1wP}4&2dfTBXs8{f zu+5l2i2N8rRhiz1*~(7jFum6U^vLhR!9545DEDCJ=3Qv0YQ%DU8TnaxIIw3w4(#Kz z?Ynt?01w{%03Lhb5j=eF!+89`CwP4n+nTqbqNv=q*4I~rZOWDSXJzQUe0qwMH|Zy} zfYwkg^L%h@0N2~EBBvk=g%w3i!x%+J*@sGvgHhrP?mTv0p?V)2d!?r88QB~8?#9bM? z&F+>lHadzEAAgL_u5O#Qy6eCJ??Z-~zGGDCRt3o1*5wrS_jMz(u!zRS29%YQ z8ja$KMW$ojsh6f{l)V4e3%GLWb?n#{z>z~GG$0m`8;YQ*wj4XR*0G)Iu>Y=dI&b6$B;#% zFceUrW5;VPjLzV7kVh@r4h6MtD}>&@1!QK`pnk(%8jA&FgmmXznN$#XVmd@TBHixs znvG8Ip7nZOB6P|Bk>Piqn+BjT<*GK0b~uv^eDziwU)t zgDNiOlQp}pww_ktELyL$Vsu~<3zKtLiqLl>;X(`l$!fep+0U_TUCv- zpPa_kOBZQb&r|OtO)$05&++5OtbHDT@(JwOcNYy5_iYV*zd?DK^6=W#S0)Ogh!lzN z(CCVeTF9k^Sn9Y^JBt-C9GMr1q(!E2Z3M$;(g&Kq4$sYDd~gs~&!5MyfBJL$@Sna% z&#{Z0E)^Qv*E&{pvT9}Cld&~9GKM!^dI3NF!9U^n`){D_`X$UwPcTnC1oMwKu2$rm!#H%FhBdy z4v&0h3ncBFaTQ;?*YUYN#-os?#Y;-3qwrx!U(O=!GI{RO+i{7{%&>5IxV-%`c+?b? z%J$N%;I*uLvh1kQ5R0qon1PTg;#4@^nd)s`=S|>WJ7eO@1mj^5{I`J5bHYI~DrF^} z)GeEW;$Ki57!6G_E)`xTJJTv`-na$3cI>86aR~R`eJ>uo|9;-vBnrNh#78TP_Al+Hwvc677!$W+kg~b`<0RzK*6*tk<=XqV5Jn&K- zrH;->WEu;M&*0XptOnXtc4QSTp3=!sQX9DEZuG!9e>oaz6CPz)=nWISHIbj6YYWZQ znQ7g^=-7zWL!+C*$;VN}_z;doF+4PiU{)rIi%Jb=nJxmG#_;q@EJ1nI$FZ1L>mjs0 zMBfUM9(no27tnsA&8CH7(FmOZ${!oXHz3;BA8gsY4fh_o-xifGB^)k3J^~qLdW%d3 zmqsizsEbNU=!r+dn3s%n zaWvJWVZ*uvHa5i3TpvaAI)=KKh4t%}EHu?6?0REU5}VcoP3vN)uL+}}HiiuiNi^2d za0(2va~(oO@hl#CpdQ<{Wa8?T*YV*8zsBfjCk=~vtBZYolx-a%4QcbT4%c%A_}yfhdiUDAf)fDeB54>M!XJO=U>yi!0 z(vptyl&MT@go;5EA~wZDeDrZZ+t#0SPfsvk_C*Wx|JwbEhK_KFBhC@BR0zQ@iiJ`)G9*8m+V!x#qzW?3{A4 zvQbi8!VYo)U9?a&&lZ`FVxERXY$1vzTE~e9v7@12-!@pH(nyfG+3^HO5Y7FyU28)P zEp2Tn%|4n$%|OgLqcHPeCp90#?8H1qdPgwOGmIPO+R)!UfOWKn*`p~*lJeyuOid)9g`k3{v;yVqKX+R<{|gfxr7*~$ElefXgbAs;$;2+dTkZe)Wj zN`S0f4bd<=FZ1}c za7g?@BRPY|I1PGrW@gY650f=-=ijrD zQSxa9ga6aCUCl{SucFJ_l%ZNyG@P8C#i_IBkP*tp26przy>UUr?Trhul_?vNKpi8UgcuD3@uWsI{VAv$ zH(FWuVQBNK;=&@+1!JRQIQG#;w!hBK9XoJ{hMX2+`bLT6NquBfji&EpB#9TzrGQn9UZk;p7mgds_*4!1Tw(#jOH@P=v0t6tSf0HtC8{*&=4sJrR}uS(=!>g z=~gYKrjz8K9)xmAP~X^Wuf9tb)5zBoBLir?bP5HzQEaF$WLw3l|FjnaY0vURKLs-` zjTCAR-C1!FH{wRXN5_OaGJt^U`=YJUviFg5Sbk1XDJ_pU4 z4x+fchS4>>$8^kt2r!VocWOlVma>Mpj?u&2wQH9t2isMR&oqZA0hI2{3}zyq1XD~S#Mh}+OKw?tEC51Lo-;SQL(XcGai26VH|znC^j{3MyB5Lre_>o zir{kVB^*0_j2-+%hD&JczJXSDWV+wNqm;~*j?1{#eihd{uKi9yaZ|(9&daz;148eE z*EH5(>#i-x%nsp3M;k^*Mr>27{QNu`614Wow-VTRJb}wsuG)6jwHvmgghDGt&#sSc zC$FC%E~GHT$b1?}(3F4_h0RU!UU6Xwsw=B7L*;n&$`#w>MI%KuRn@rX$USImYEoec z(%`TU=GZ&ZyhWv9L$;pM(JpafUkRbuD;l8zNQGHkn2)CQ>uonR>%Yiw8jVpW>86LQ zn=_>-Z%INKr@^%_H-_Q<>o|A%4IF>}XSjOd6&g4bIIypgUO_n`3*)G*E=OS=z3QYa z@-U){6KwBE#KKdE&W~bhtb=@T7LDt&v3pxN@Au&Pm1AgaIgQz=Q8J~5MOin}QVYd2 zmn~71V%H~j)iId>>8_ABg34lXM#MnRMbuURm8Bu(qcssy0}@~-@TSoY&3)^bS~otR65F^&V+epoj2wXVcnY=E9fm# z38qDu7vp$kOM%=Rc-!*RQ7xAwkI;zdUbN|qrj7e(M3fuOF0P`b8W94u9G~0KkL1Ih zy`ABMl%*DCj?!C|p3umdDae$#squxsQ?OEW%VwmHOpcl{aQ5n1y#L92`0$et@!^>d zalZ8&ItM%Wd;~L*8Tumh%CeR*GCd5vn^IU=fULrheSmM4&j-c_&@8Ye6mwiXU>1rEmO7&ZZInc^-UXSL}c^68@X~0 zf#MQR{ymu_IA_g?nMsufOZ!BnD`6IZ7(Tnv-YN)WR)kGAp@UoSUV->OfU>H5%&acxB4Vqtax-JKf! zn4&xYMfpkU3XSqnj?^9+ePO0c8ZBhUpG+_;v2IK5I}^*v92S?&V>S|^dwMnv1x*L5 z?i#IRTdUpl5k9?s$^I)6A@7|0vg1d!6bi8O(4x#im?YN9lpZDD_9zBC@jAp<%<_w%xhCe#%;D zFB{g9DI~KRArk#`PX+-mB>3Vj@{ryR>~1@WXt*2es+Uld$MUqVh2G2}-eg^ZN+&j! z%;^o`W(JLzGp@6IwD-_qj^tqd=KUzGq(wqdTls4UmFf#EtW;+D4E7E0wa&`Qa&OTM zJHEK~Ltx`*n3=|eI!k?=s(J19<4j!+7+*M{)4b zAv85K(K@6zs1BI*C8;p8IE{Bcdcqs%%9AWe!A>Tq{Ni!Uk&fEP>B8A}@!|WwL~qAO6#g+h{9pqP z9;n8KMq1m;vs4f*^h8Ma;cm3Blem8E6k2Ib-)K3D&bD*tprLT1T=^$)V-H3f2`V-OSM&yAalXhh^w$5+RUb z+TG2Lr@!CUM5OLmDRBCP_42|JdrmFf)4HG8`5An2{tVuH{|y@dAK_ZpH6$_BMYp#D;CnXx`e4 zbP;aT^je2CHZh8p*6Wm8 zz5B2Eb8Rd%k1%yuWIlqh1-)9R*9^m0T)F2xpXIYTJ&)S<{EK$Yb3W6a7?g6hLJ*U+WK($+*O39W7xmzAf9>rIc(pwgECJ%>1~tD*1`E-VRUN_9PZK`1Jo_o zTicM6SAd#z4YpWMvLOx>2iw{PCP>6Jjg(<)xWuEfpEMV)H4x?HrPMD4)}C@uZnU&u zd~A%y;Z_@s)>m4WG)3)A6RGW$&K7txf7_v#I0`{C>Y2GZSuGIlq@z+(U4!-O%W1S| zy3S2!6&B{8AU_j>L+$9N)0a>B6y#Hu^(9Lh*fGjXIHA|9bJ>ZyT`vkMhKfcnOCI%rcgLRm*oak!Bt8HwT|fea%V$QnJR;UeBA zT^49Wv~`BrCJm_HxF7i%?GUXbk8A@qj6R}gyT~a|H7TE&97$fO5Y)MA6SVG*vvfkb zQCGc;(gN+bO2dd{Nw!3PYh)T60>e}7&mscEJDqK!Y4mt9iw^b_X2aQN+`Jd1RqH4V za?V5>mF@KC9lzo!&;pKM{pwd}>uATfzV$6OJUc0Z8eQ|)+7LvdpbeFb5y?2lE*cIY z7;}-i^*siy1z3zyxMVH&C6wGX+Drb!)Mx6E-xn! zIXvpsO%3O$%t%JWYdBYy;wETWYpUXZ{O}(!PS1D8UE7gUNROJd&~%KwdMRAhpRTs} z3Z#SjqOdg1uv!}HS=RQ7-y7D?a{n)eEx^Wu~iUKO3INpBm zeH=gbG4?<44Ky~cH-+Uo92p8u8A#wYU#zlav_-=U$fD<+P9f1-7l~*LmoJ~dv17+D zF*-qI7{Y^(K8o#ocO$o`2>QY&WzNxyR(?7&R$f}st&bi-3%Q7=&E-F zfb7gE%QP%FAPu0kSg*hJCMG7vaQBgW>;po!{)llYi6k8HBk`0* zRo31sv1k%Q1B2-3=t6o&F7CPKARc=70aVb)P)Db=Lz;Kinu8dP?i*dL_}Q<2f`PFf zq=neoW%9X3@>s?yWgV!U)OM-hv4dB4remHumo92Pypc{&gW;zwz$fWYwOnqow@_-z zYjE`5his8(LAJh7%5pW5!8|owPv%5Ci-xMzQ=G5hLQzv}Z-fJ{-)52WQ|`0%aLpkDH&j~LeQy2m<^o=i$Vj|>gj z3qSat?b)$I^0VJciYvTF(YL`i>GSNoT|D`Yz4GE8*~-EtIU(h?eM_I zp<~$UyZnC9l=@O0YKy$Vb2*_Nl^)gZ-nn!F{G&jxNVUBGmX>DU3upL7Mh3(U;o4~r zJo@{#^}yq1Y)_C7!-yyaoQ#<+w%*g{?T6p`ch=T)!ybJ!-)d@aSuzFv!@rS_v&Cat{cL3`xO|Ja)4U^>oBBy%xQdOr6Fy3(eB zK4)GCA0||L`UN&IHEl0{|3BE&=q2%S{F7?35MqJwZ|XE!jQ%aNglsnn!RElkJ?oAkBdtlwrcnAFO=q6v#s zp3P3q+4XBTtZ`kV)vl{`?}QUDrDxnix3tuP0=>QYlwOT#fxJ+z2_~<}5@p79p@_zj z!R)C|_jXfTgC#4IRv>-3oDh>IC5X&@ONzmZC4#!o4Lpm(-S0jSP;NzH6s4IdN?W!_ z7)w-x zD%$0Xv3$v+Xiayck6o@PpUi@tI(f|AIr^^EH#ORmUwTT4qRh^mJ1eG@MK9y4tSBj= z6+=ph7~iGWfe!a&RS;GguW;|Jlp--fMqqV?#f(&8>a*h5MK6k$w5rUN>)*Y|SiQEkbiC=fa{H zYi%*>*;H&>I*Ys!Kd^b(ws$CWn(f|JWIbI=wx!3cdvl?!t6dWQGF(#gR;)Bi#9ft% zMccfg$Tn?AS!MEyOK%}e`v`~$a7+R*jm z*3^)+y4q4JgNJ3zz&AcmNBJPfC|Msir%P1aMGqQnZlM^5IT(GHZ`kzsoD2wtAvl6N zQlc`fP~v&Ai0Ecq5GZt{f)ilQ`qrGWT|-V%5% zp0w#L0KZp*eXO1cZ){vKQ&uIZD?8ct4fI=QSEp|wPh*K0$3F}Q8kIPHK}P(p24HG> z+FttEkFEc9pY42Lr*&-Wuu92KLKVr9R_c)OM$8PId z-z}c)v2KMeom;J}u-tC;4codzoi&v<+sfpM^`E+C=Z>5+o5{0>_B~`@fBJdbvwe@1 zN?+ihQ2soYmcp3M0PvG2qg$3#gQtq@4bSBGwDisu$wR5NZR}K|ARfk1fhQFSolwMF zE#14P>$IY*p^^1r9bG5YP@O$})`oB0mM%zGX<3DgttQ{bnxO^PSvUNvHsBpyF654=LQR9ba&bGWV^j~IAwDMyX?Rh|E_Jl?;$HHhgJpB zTTJ9HmmZLusEi}ySMA*S*VX>bTW4#f^s5wRVM_G@w@FWX5N%aYOA0P?-ZF|lGLG`q zKCBci`sr(cVS)yljQBHX8 zmG^CNe$d)m@~paI)}0UxgcZq+_gsqK!N~(M!w#Q$Dd8yGsX}AHCzwqHk1PBs-9zeDVs7&uw*}fDDL{-6b@2wKx7lbem;2zJpP;_Y?{#fb? z1G-lrWnWoRVU?wocJ{wUyvvk-udp`qihRd76~Lv zL^Be2)CDxBM`C7b%FdiRW$(W8jx{tl$vb%5+B-V^^fmUa$9SSCWX3f6<2x%$u+bk3 zWAMOxW0VJDjTi_e%yCgZqa!!1|JpS%Q#gGCB(ACKY{UwTB@C_!l7+(a2PS z=G)JzPDum*cO}2rE?>QA<6{ffzNKCUZ?y<3fu(^)8HiUru0EsTg3o+dg2`hv>MGZg zf^2BJba~1a(lerAgBmpyD9VSPBD(nDX;&HY7*I~a$kP{E;HUEB7bKJp6DwyeThL%# zTlT8)50e>b1Zk^Xnd`UycYby71Qb>utidlcCjuTF9v-%>hjm+2`%S2OpFywYrY=Udo7WR9HOh8Gh<_c&gh#QxVjrXXbp5 z2b}dGoJrS@^f+{KP4IB7li$kR~Q3d@_d@ zR;BWSyI@Me39opFV(`YGz4!im_Pv+BYk%^We`tU5-9NT(zw|A8^W^I`IC0HZDwk|s zSFLT>zTUP!wAJ=Kw#V-O;(dPK($j36yW4HoBRg#OqdRQVzAkI(skgjpc^uPy_SV_g z?K`i2$G-9X|6>2?FaM)`>xFOG%WwYF&i7xix$KNBt}H~$IG5yUtI<;Z;>uBdsAFma zs}Eq8MU>Ld3&6eZSD(2z$Z{+-h-w&D=&DrXkKM?XGv}L|@sKL4Pl9vv=8%L9i*Us^ zrJ9+Z^Dbc0;v1L}Pa2?0kibMI0HNn#qqAqv`z9~V5{3sKdPLqHV^|5Rl@fgH(Z^j$ z9e@9Xy?5lWpSVi>utG%o%DYnWLXr{|$3ygpJwtY#V^%$kPfrGOUEnw0k(9Cu*k_`D zaKMfpJ!&_GZ`;Prh1R{j$~LrDSXmWgkBreTVoZzLDd8o>!HI{;lkQSx{1z}7$>x=> z8ot5>eKRj+o>$swDW$;hEW2Ylfwu~sYU7pr=7)|op)i4K-jdS~%n^9_+6hbJcFI^xnr^u$|9n7YR6_PIGgy~e7 zo-54ryVT|~Wj2#3wdn=omDtQ;u}$jz_+nCND4yQUER`!rR`mT;rrf3!XChT3GB z2t1qQnbMrk5=YF_H8SGu4+A#LVR#eWTUJr-!)(Xp zRJ`)a%Xa+a`#!Y7=5}La98@;zXCP!%Q08JGdWofCE6y*s!lk6;M?IAVS@DjpOADgM zT-s6`I3xpp^yZih$bLKf{uw*`)_eBen@4;pbBret%LCwzYV;f99XVtfBv*FKBor4Z z|0ocLkkow~_?CTb{F}n0-*crma$#=Ku3o-s@4b7(=4Th|$tS*Gzw_I_YrA*v@i|`5 zEjA!>VeNOoGp27Yof9&A=q<)splo~-f9LVM!N$N+9b`mP;&|i2@O8>p*1h!7Pkn2C z4(mJg!2NO*?zbnNdeV|gn>}tWoWE!b()TftZXD2Iah@1s@jdilc$|Cou>nW-ZX#nD z)Sx@xB;|m0_w2Uc`JKP3>pmMCoV1ClW$C$%R-;hg)M3p{>!k-}>o1H0=vovev6|MI06JNEh+xN+VDUp6jIxRBdFNRs~ zhOqJ@G^_;-FSr3gBcq^(-Hi$2Nx0CXl+%HCUSH5%Z*lWImD~`6Xd%bOhg3cAI|>kp zxTNPj7WAHaBd5G^w!p{Tw>6rRGKLAb@Qk+@){$`?0_g-pkNnnjLyVgY`48)f_^2== zVPm&StZ7c>!}G@)@(oXvNg53%CLFllXFqu9`}XQvuh_`+ZA(-ntfh0k?LM?iVW(~0 z(P?d6s@#qiYiw(@n)Q`d+fZwDjq)H=(B7e|zP0Suy*+wYEl<6{*0(p?#;y(4wYAf_ zw{=_VrWPxc*S1`g6=Mz9#j6+nIBzy;VZ#&FmoG|#ynpt%sTsDq<~m<`>AS+k6$`NOPurLj*n!>q?coO=^6tBY6v_E> zXKhw=YmtYOEG-xP~aq@YQuGld_By!lm=)&?>fn-yUmF zJ$n=x0N_G*J%$V?nAXWd;4G5aiHq*J&dp5-SJ6X80NxcVCt$!#nmG?r@M8F-C3%k7 zAaD#cFkd6DsKgf2GBolyJyAHSQCP~ba-+yrWIz=q)L1OfseDpAG6t}mGs_8mld!qP z0_zZd;T%kv$OoZq}TY@_27HaR*eeKccJ<5MbJ?UX`equk;nD?_N_4+86L4Q zdCvV;`^49m?b2mdFkSQjbwG;0^yua4Z11%7H7zPgi7cw@I zSbf1ubm`Ate%T$6y?giBBab|4)zvkg536nHxfvW76z=`L&kgzvV+3sQ;xkO-Q9ksV z@hr}d-72MdWH?>2x#Dgp$>m{esap0yHjtewpn|O37w5qRfl)l@jc%{;_CDyZb zueEOMQ8_73s1=WiE|$#ep#m~5q@;^U%O_lm=b1}1IXQ15BQw%(8|>L}j|5 zo(?OnER{(q5*JA5d@Lp~txx{I8Y_^crE7s27rGZ;eBnjgePFL`-?_yKid9gUgbKh$ zQ6q$K#cR2-vX^JoFkHWFCy$-7ncFk=_2+-X9)9ox+x4&iG zUG4V#v(H%Lx;jwt^oWPDXF?kjmAqF(is0JmH|)&m_tfy6m7-a&O&t~1)m|!wmZwpa zk%w03-O3pm3d~%YmQls&l}ji9SX{io-u49yS+ERCbqs4W%u!PCNZ`p7E{y*@I$k-!;8T-(f;T$EbZ5_OYU870HaWOAmKV6R^1L$(L zy=JqztaSeVefxYjO7sW6F|a5ax`24^yz`#wBW;g8{D^JY+U@!WT6se!{d&hKphAlI zUFn4xd+7)NO*+Bs`LBM(wrp(h;SA(cC&w(GVaO)bB!NR#PN4WpI+AZ_ zD=bjSU6|Cj$qYierp`i|=n6G62}0uJFWykEVF|EDAmuYK>|NJieahYu79uaxxO zygpDpz)NmGfP;`>r3VZY(Z_o&!~vQ7tn_NN@VRco6ZQ6mXaBKn-ua-CC^qKM0nm-R zU#ce}Zg^Od*xP%}x;i`j{0OXS-#S@^qFlGfN9;R4{El$W+V;KMt-Pwt3QJTu!eSW( z3bkXSKorfBRj4--mI5igLSGJb;lc%5-`woWB>n6RRVoB#Vi9q$YFU}!lcv;o(^!|6 zmHWm^N8UYZ6{QunW9xP+m1lqE%xRmSU+`{6PDD(}DqomcwDf#d4SdEHr}SHmGaF+4 zyudds&${5smRR{e!|{)`&~B#9)9RSYieu|{l!r7 z^4Xr1v17KDMoJ3AKnQ$GORLJB@LlfkDlq&QmJ0MNbW0mWNxv{Vob_G3WEakzvHK71 zlOfUJ!!xX?!G6X&@GeU7)E{+UCgr@LX^WKLUU{8ec5^r-Z|}A|?pY}pHIq`@OuEgJ z(YKOUYNL}&_QuwdEG9YJZ9OsajTXBPuApHsjj7EN#7HrthCf;Y?qc5TUlA5_$VQTRcytjg}N45 zd3Bj@Kc3=5$@G#nHdI<&O}SMlZdFBzuAHw@Di2MKR!OmVlQflHNm9JYZ{<~5=JR6c z(xx|sy&)PK%dNVm$kV9+m$EW>I?5Y7N|RPvu0T563&(PgleDU0)pbd=%$HJYZr^G> z+ji@FJVR*<(ZRh>6o*f+iUAE5ei7|%__%W-f^rL20v^&i8L%w5-PGCTd>2FfjW;$P zU%ajsWg$K1`#7ZZSKm@6I+uGLG>Ffeni^%0bXxb89^1TSi*4!Y@%aX9x>lGjwz=6k zPp_u7(mFP8v@RLh+jeZT-FtT09)+E|w%eBNqK&?9ZEunCrfy1PY$j}caMIGLwDolN zD4%8-5iG}}M^IcR+?XHWIO`wy()bbpytBLE4g*2}X`DXjJ4L6hVXVDeb=1DG!%yr) zmnMo>!bH;-8VPqJbY(8V9gvee$vSFIQ_m>SRgu`@n)y-ZFI^TLE3H9gE5Lx@oyQkQ zhF3Du8A6JAPt0|A`|Y>A{-}2xMsz3NkP-EX?x10XP z&v%r8{LnAhL*pa2?c(_}*0R3Q_UznY)sh*ptb1#fGV2fap| zNGue*$#y=^B}#6c=p9BN=U;e_QItOOyDBQHCCAcFGC~S`Wyq4+7K~G+E4_$EQOHmW zAMl;tjg1W1#k23pD4n;?HtCMUyq?p`hL`S<6qyhKzxAEVU}Us2ziK{}kmo;R$&yBE z-F#4VPJ8Nk?mI_wt3qoKewNao3hau6~hA%hiB1%Jy?E7jZ`)(fJ@cHkHMSZ1Uoec3l~;~3VFKD8 z{N=YSEzjeT$G;#CX@jLj3-S!QV_8=7v>iKk%-%b4%sM+a`L?tT>w>Vdf@6_Y1&Rz^ z@c6ODW5I=P@Z0|P- zDghKKo>-uLNFN4q&qMV_F9W>^bF-6n_0mZ@a`;6XzHv;7a>BYd*IH|Hr5ed*8|5ZUgV3{GARG$6xXZj*jecg zn&y>4@>DHKhol#>cKzL3K2-aar=PR!TeiyqD3o_w>}6p%!< zRwqN4I26*m;YCrn9f0+{LDv%|VsrIL<~SzqP)dJ!BO4&Bh5+IeBHSgD)DD5J4gdfh-rE^E^`+xE8t-W!=o_wg%YO5xMql_Ts zgn!ItbG{5yAMlMAKxOwH9ENg*Lt1h@I$LT#`PogYtl4LWp82OTUe}9O=pE#m@&r7* z-Xrj;UDX51LurpsPTE^P_%}8=a>5?iS8g56zHr>*!>`OqL+1ptC{3kDfCqdiU&=C7 zw4i!iXD_{c!)}i?*;CK{Pu8>JVRueBP!8aGYUbnhM1*fwfpIe|K^hzs3|(;>H@2&x zu2zN6FypJP*u>PBojHF>9%P{{$ilsR^|JL|?X_FCM{Ig>LSf1`SfRU)WnJu+g~`eJ z856^kHgRjxIPbrzTmJ|iigY+>lQqo^wGHx`H`w~RW`!1S)HgJ?+xq$zYpHMj(6B*P8Q0eO z4fhP|8(O9Gi|yK_KC7)Ncl5ofkuZ(sz(nW_92Px&E59U0}si7h~}e+bXd5)!IBcB#{z2^ za3!lcL(r+prp6|D2{m^9{COK39P~LY^xhySY@h=!y_YZAnN!E@J{fR(cI~h-dBNTl zCct2ENdY2ZC$MeyN`aKEEc<1YZ*jRbuJ5ufTMx+d>9ENuwspQOo*0%9uw=K!ifnSW zTn+Evv4@}dZR_6tkivb|z3q^7Z{06XbB}G>zRwz)+w5w;8rSJLyK#HLj=g`=E?*Yw z=k?gmJzupie&ui4ekq``T6rW16jhN71U0-e2x(;eKq58h2_{quUla%9qLlN-_49Uf z=&)_>GBs{l@yVoBl`Sfy6f)5@W!3&{(JD$7tfpegs!Dmcq~FU{S6OUBef^e^fwyB< zwe@VO_Hokk;yI67UY4~gzNuWYO46*zgll=)Dm`$p(z2BlXJi0Ri2rZdzFke$(V9@& z>^!$*WfJb%N~Ke_pzjv+EM*nt!bw;1;u)c&Y~GUPMb+s;!`L#7%LR-t3Iy{$-sWsx3+FSR!6#h1dx z&qvYM^nfy_CSOLs^6=t|uV?z_-1F|KLExiqAx} zJy=^)qi6KA@Eu;dj2Y=^bSMWaf)7p#r!&)g6bv8XLIb4Kp$$+}#84b7wi4p)a_5iU zt5^LLwXB!lo*b2PU2p@ z+GpoapR<$4Pua=$Pucl%7u=9xLQ416&Ax=Eb3=z80o>BK!S?Rhql&KavDo(Z4%^zj z#dpu#+P%%Tcki?v3fngC@cV6Bw%Ycd?H?Mp@y$J+$6-s)R;#U$5MAlB;=&SH=KXf& z#2MSQeUJUt^S^2BZ5{FzX^_PjGPYomSefFP6qOdslRD$eb?DxtVaFI?80Yk_Hhs}n5{?2{)?`U(t|tFc{miInk- z47_7U-}7|OTW)*qd)(HoZ&O22?2L?-@i9~>EGRTHzhLhiIVxT{YyCr0*4(_? z9(wfm?C~dm&vx&B+-e%?Y>7?AiU)o*J$~_WReB-x6U}j1>6A8B5KNSXW1_)m4ih6o-R) z3e^mfH#=d18*?RApOJ?=NXM^W%F;`DcC~L>nAcm^ru|k`9d<_*DMeQp1jUJ(3d+^N zZ}i{lgEjcLMg)yb+$h2u^jb16f~~JRJ3C!h#BsSYug3Yx`3QM1)%moYK6g?jOj+G} z@oEXz;T|cef1mMm2VLgo*+qN8q?C@aMmyzW_>`4;;_)#TedX&liuOD|8(n zz3n=N;ZAxIcJAEibR{i%2%K+(iyJ(miBEu(yz?6L z1GzN--Jyr_CoO-1*5tQd`G4pw3_8e+0ii#-GU|zZ}Ur>u~eXtFXKSQu&%TGUS!^zLMG|=Y>YRZDw2_0ZlhCqcDZ-f%Bwb5O9z|e zRXV>>eST1yGW1RBK#~7=9^KG;?#usGpXn4sz(;IqYDD<3b#}g~8L_z(PJGf97V;gR zfM1cXM4L+&>Yl)}0$+_bGhbmBFHw$a+te)wuz3SbmC79^)Wfo*{fUhTn9=7Rs8Gx< zW)cirpklq#O<7S==1jm^@Ts|J-`Am}GGXi68m(!=dfT#Xi?z13SYuP8l~t4qxs1)s z%-Ss}n7)BQG4GI1+Prz=rky`~-Uj*yY;1hYmuHU20J*OBqhq&ietuC_dXX>Z*|uY= zA3b~U&;eOa9p2l(e2Vj@&wJ0pj;%X=%n=52%N6_x{Hd}S&Pd2g~QQDI)OsP6+&Tw>#6qoV0$HR?$( zODdDH_fMX%!)n;svz}#}8(KD~VJ{Lhhw@}(J(G&_mR|{x5QT@UN(FC%`eR8c-(J0Z z*_LG4vyvrQxNQA>y>|BWNeT82+rMX*3@}znFi#>%2+(av4dub}@CzJ>hb)I*dJEl! zXHijwwX}9wN5>9pT;J)dFcRgJw&wu*(yy0)KyLvPy_8yH@hUN&QQmj`hRT?4k3aQ{ zJ@M4@ws+qnR$1Mo927-zZUrgbFN)DsCN^{uPI%jS1#~Hj`Slho!)|TiJv!y)^-DH1 zaLm^GQQ&O0MPni4{Tc@plc%V%=sOA*Do(fx-{n#X@387EWt+FkSgo#59fxI={tbqP z$`W7MUKV_UvJ+kPKlx-b%XZ}o+g?xjw%0W^@}2X(y&XP{3<<7 zTP9l}rIeOI)oNW`_gQ=Zmk*ujN}c#HjlMup`cQ`M9imGveA1o>_$_`$j~9W-l{g#T zPzV$=#N#(t5KtNCWJH`ge?p~6%ZONKg~ci#pNNK`7bP?SVdE_SO2Fxe3EzsjxFliw z5ACtGj#e4wNvN&lF)KqIgqi#pQ~pNZOKD44&#+y%8aR6TFe1WgGyVd;s{oq(_W=mh z4G{3mjTh7AZdiLBXcqW}Yxtwybh+oZlqt?D@C6acHv;EZz0);WoRpC@=m%%jt*cXG zBZE#+*m{3L_59wu?|412hXT8cVi1rgJ$mTIwfqA;sJp;Z`V&p*6`<$Oj{#SFdaCDV z=fpErzSjf0#$phyEJ>#wJ!}iA=LZiRw9T749S=tp{SkhV1KBuuwfi+Hqi}FK@#p0w zLi8OMhxjJCuHw%%PCLFQ1y;dO+t8o%(HDBJe_>A{hEM2;U<0oyyK?!G46ji+x-&L> zYsRh(PFP=m+O7?x)Sk`jx}YofDPP5P`RcS?6ORoH$zixYZC9^P+GQF1H%DahH*K+= zEeEWulx62Cqfo+#h~)#ifLBZL@Y64)slSR$Ngc??ROT+xasWtW6&7o}GKFqLL%F#SkD*dALLj zg|+GT&YLjghX-#U@9}&!EUg&(=CdS^tTb8djko?O&>+yT1xA25 z*{4DgtgdFA57DsP3oDcH$=6TZkeJGj>xTbgAEF!P)S5q$AvgJOiw z62+y~x?!U|^vI)r;2V>|nB>H`Aww1}R?r}yE<^E{Xp1rNQgPblLi_ZDTEp2p)C4H%7cRFN>Rlta`+VwUQbF{-j=aDZ=1K&T6ImC zJTeRj3?b?k4II9Y4FdOw953SVn-xvwr{~Qsj9PnVgH_cDSKp3R-zk;ApZX7URR8!& z9R7tN!Iilc^o%TJYGg#PHF&diY(8LB7!gu@K0fRCxC$T{lD-poco%#UBf=XOXOaN) zLU5dPTU(oNI7QdDE64a7h47oYGtpqiPM&-J4kIGqA7lpZpe$ih8C~RhB3hOgv2=OG z52NeZw#iB>i=_ZLR0=*6kLX+K9~!u~=Xuk~tMtXLGlT3sk-n!9ky~EByX$xObvHo) zp4}PT5aL!qx@&1i4*}}R%a@Z+&i$J4kPFd!z7wqe?|hTz!=of|>Eb2V+qKoz?yMmD zZ@>MPH~P$DICSWc*R!u=(zgtSX60!3>I5Y~x}J87jyLS$CvcWAR-7#H_8_~ImeJC0 z6Jz6k5^!Y&J3eOZ%Ej|`>f~|TvZ>Sd@7pV5g`NshKwe2Uq{khT0avpDpH(A*dQrVO zgx|q12e^-(>YMO3zUmuDf|1~cLLBrqaw39k@*cj^Bf&~uMl!mn*Wwmd1r{cKPMhnU zWrdXr%gAugF1M-NEtZx1Wmg&%n)GbF;xtOn>E7xTYAm%>X4$+-D=2A{PTQuo@F8ny z?o@rS3JV+p4V)gj_0872ULzXmAtL4m2i+sf`UbgTH{HBqwF~9Kqgwf|Q+{ff^VaK` z?pGQdugr3@q)U$2a+97n={sFjhLyY~tEt;++jl%@ZJpg#T$Xfw%tUm0AOfw>r@0dT zQ6c_9*LZ>v-T(^Xo8SDV3YBNS{gvO8SJ`TLNm-#om+b%e#{XvZ?X}jut4j``vQ9|p zDIUy4LM-QkF{u!aGG+N36DA9#>|guo8}{G6{oicg{yp~W*S}(wb(QGLFgE7|3=JY< zTfRS}lmx^r&P&?u!7=;JAAZLUY&~Ru_jmq|4{Ok!$2PfWP9|vqOyZ;x=$Fu&&`|sm zpf=QpR=)XXf8w*}pL*&kdEH_jDSf)w?*I%ED5$ESJIcES#RRFvCA)l~*Z%x3zULnJ z%)*@Q*t6RXJ$%2hp;Sq6nF!CBAWOdDfiB9dn2U)oNtj8%iUb%DK<;87k7CsU+l-@l zVF&IHzxS@6Z;)7;wT8xZ_M2b(n(f@N&8o^uoxa}lYMq%Yf~9}F*C49V88etIZ94=M{&fY`S`55B?>2^vM|h*vVywVKEzjlsMOE9#kg zQ^U#r5lb`<@K%AP>FP&mAXoyruDzkrdp8!9mn#sVf}L{s-5=VKH~)9rw`0+^_Y_!# z_$%;6%m);b51q_~MY=9=&Q$KC#={JE}wn#(MRg$`iV@1AZtI=*H)6MEH%` z%Vo}@X}0gZGH&y>!_UrW>*%&THqn<7r14~o)RlnZM{}hF5pI#<%7u?Ju`<+ogr|57 zSq0dn=zHJ$o*g`R&>nsCQJW)2D6YhV`~jdW@lm zci(zbCCb{@o_|h8%VsNPMVTswF?Xl6_!Bz0=sJWlBXcg>IVd(jJ*Rw9YW(RD=G0J@OQIh!dTHl$ zQM&LQjumoGJH#Raw%=#DdR7g8X=$VMuV}1yo;Rw>5tiqF9%mecZKc9>b61x=^M$9pa@p>6+twaytgn{` z7d!$N7%>CA2wbrpoWWGJ?ma|R?6)}}YWvU=OPAPzk}k$lDcR2UHmfXU27ksLfB0eB zx~1C>+wy$W7|M2EHi#8Arhm>6jYfBA`730I7LMap%hEmFeAEL zMgzz8CaiEd8Mgmtq8AE8Mu)QYH2Idzs+fSAEQF36)l3(DFia9j86bL}W#4G(U9h(E zTEy%w&{8n$6(3p=2UjwlOMpCt&jWJ}7;3=Z7ycwoeTkq<0uHm8b(L^pnD{V>zC!qx zb&pX*2t5S!R){vDM+nptN;3RjBEINFeD=k_Kn!@}9~XFDUIrhS1Ru$gv`35|v%teW z23T6Wu%PRr9254uAP^trp>h%w^Deef916JBfPGR3JQ9?j8_pOB0*nS4CFV7SU2a1h z{l`cC9YNftN4Nwu$3fYB5JH;X7VDoPXf;C%a30~Pq=9xH&INohE<{B*C z!^*W#lEZ#I5MN}juCDgBVc+iEcKPB3`|%6kw->(uJsHf?_Rxb5SkI;|$+UC@RT2|X z042Ov(o>DVWm`2G>uY7NY@;aQfv^oPqVJrRetRcHL!5hsHGog}Hslp}$DK_*_p8d! zWccE;YAdN|u!7a8HzV2R>JD=TM% zvkju5^d@>PE?mqP-~c_8KQyAuVKSvagmc46xAdB!M~X{ply0phOB$@GlwLfJc@U13 zRhB5PwxUYCCxBOZr4@VNd9@XkHd~@pcqOXPCO))>JUSo9pvJh1>2xQx?t;6?y$WCo zQ;I<_9=ve+jg`7p7A)szoR-DOJ_NpD32KpUXBi5p1%SOCKqw|iU>sGx1eCb~^aL>G z#70o;4e`K356F|+sJvpv;X69+`Ig2_aTQQHF^^{=FE}20fT6t+R911`0Hlw9dp8l~ z(es}d6!GT<@M2O?dq;;=)z)~=3yj80Yoy8b(Q|LZhZ{8T_c zj#u%p#Fv$aLcBe!8yC=7?+D&Y5C^@(!|_#1N|vz_8xwfKcs=?yA3PL7`WR$- z4jx=p8h62cEG6I=decNV1q^{D5j-oy0ppw_$n~MhAKrtfQgGm^pL&RL{&c|`5djSk zJi_EWbOF6y7_k7)UH;+QI0zReun}C&g`clahql5v&@$9({45w*L6WG|(Xj~|6i?60 z&AJ1jj2sVSB$PUU%YA$wT>-}_y51dPxc9T@<}QdiF@&|hz?`cI`( zK2cbMK!@D2DS402cW%t7@(I2jzl5!0;|6>BOJB6Qs&cz>`Jz=-l-mB?yNq2$siy*k ztUD`8Q}jp2^V`b{&#Gcp%O76_u7|)b(!C1{b5Q z6`P3cx{2~p2z8+PLjOpnxw5*;`ILNV{N?Se*t~vY$kQgVCTLz^QTOn4gqHFFXZG)5 z*Ufwm)#Eo=MS1xZ*`e;Z3JKZ>U%5hCfkzINBAmeyb*pB>C&a^7^fI7BeNw36dH$X^ zXC>pv%fZ(#sOClqJr{ZpB2um5gW#a~Gyu;_0mn?gC;-|1TnH6&4*(%gAuth)MIo{L znazvUfBgMp-Yda7?}hLNG^z*^mhyA4_Y)CO_)@e|iZlVFMY<><#!a_u*HXcKC?B^Y)uQ)7{Svkf9bU zXYQf5H%`ik0OL55kPTUiAP6~hO)bp(605UkPTAXUylS&E)Ar>reZd}n=#Z7mfbcUb z^bN`by0M&#?}IRfB6;A?p9N*7Vo)T1=Rcq;5(ymyiKKq(*2$r;wjI6F*6%iA_L)oE))n?lu4jG`Nlm1>@pfW{17mw`At`| z_af+u@cJqbr3xKG^Q*E8Zwy1Y*NsC!d9R31!h8+!3s;N~6eq(XdF4u#P0D1r@>~i} zFZ?@*PBVl@8l@&6H%db~DBxcn;sym7j(LVIZh9kDjR@%gp2u+k3}bo*;a9qN;a%Rl zxPZQHXuN_ZR#_B+0k;+wGHJVb<+7bPeac41$BlgiJgqoAZ%kqe1G)EW;Q2b$S_s-;tlzFeFkjHe!d%&O$I!;d%^ zF0%sH#pnxV;cW$=Di1$Y4)ReQGM~&zxEheFxGwd8G$Nf83yUmMv)k)L{G#xx zG{}e|A@`m@!>y;^Zsj<)u8ZMAMH7 zRvea#x&`es@8Dj$$HNo zvp0V7Z9Dw>53TR~yEZ>`*;dAeY5#~wUjf9G%gjvd;+$JjanR?qw6#OAatXvZ6CIrzg+@4Y0V5@M!?*fdIewZ(W%CR9Ri2Mk!^J)0{Ct1Frlq z3Uy_-K^k|Q3E#;zzf@8n8Z8L7nLJA@CM~gCVu_+sTgGt_Kct8F?#gEdM^rXZ^s?%g% zv9Jh^3xMNC+6;qX(D{8tuhs5$R0ubBhYx|c5&d~^cT#tU4}+ki1Ad|5LszOnksM}K zrWJTVR~oZzTf6NmPd{l79N1+O{palXt3R|$N8hlq{@Oubi=cJA3T6zy7T4*}cOO zC6Wu#6P@BD6Aq!71m8e!FC4c7FDQBfeG!I1gAPGcz`F$Wg5E1G(bobA@NMAv5cDiJ zl0%$XoDV^=pZ(6VS%wKRGe za1L}NP<8@zBM2Y;%MHKOW>Yc5#zZb~xJ{E9x$1S*HkX>U<7ZA-Sxu=mw${6Mh4t@~ zE>&pnR`Fg1HR@`)vU(10ugMkZ6NhJF2O%CR0YbxcA~kiYckHp?MwAB^D#L$rHkkN?2z%%5##)feS3D< zlTSWjdv@)%>gr1IuvkXkS`h01v673!=L1yvt~3?mi|U(FA<9lyS}O@C&u_~3x-oFV z);H!^eXRt`r};w#L`;UN7;*`NRpnoH2Bb?5Aw$6FabkOI&fg!g(eadZZdqqFb=68i zczE7Q3p@(d*nx-AK#|jHK!uW9*oIp9r~y59Y2Ie%XRM=hy;awi8AqpM#4YO`iiIVm zO4P63Q{s&Q26a%7dHHjek~eg@e@2aDgLQQ8QRBTX~8>S#Gwb0 zx}+H?5OFa)LW3fsVkT|Z`v!a`+_siBTPIu-`Rq?18p$9lE-Y1Bx8%FZ7Kv^Pioe8G z?RqxM?zhJWtfi^NmlD@hReP@@WuRvzE??VbbZLCXJ%}1LB>}m?Y)wjwCJ@Y{jHEmXDqFH zN~cmbKR2iEChfZP5GSL)fBcvY4)$AfQ=>ip=%aS%;6C${bVV-eDUdBR|ABgO_`CzM z6qV%z#fkak9_92$p}x>9{6rS~s{b&s{N4k9qTC(txr^mn7f(y~u6zWm&$tbSSGWhl zbN>}Z|0wXO7!knThJ@QVEK!m9GnbyV!|xxql8S^M?M?51FJF;`#MuR?cE*xuEYYbP z2$PksfU?6VaKiw?pnRP`^2Aupp(|bN!3fbi;owXO&6ZXynO|)4Qww(H*g0!$Xtg~% z_F6?*IB3dg6jPsD4^icY+W56VEabVuzD8ThE@iC7hNX`sWhK^F-(Z_IZ}!87T4hia zC5j}}DPNC0DkB18fpa!EF93?Qv^3h@ojdH2haa+s9ynyXr6ily)mxDoH};|+P$O~n zx%kB4^8qy8&<%VQ{6fjNmFUn23qlv_l3gFTAmi(pt#8V+b#;YSR6tjr=-^){UR3T_ z7U>@MC8SCRn$Y3Lam1Id+^}0C)3#}Aoz>M=D4*bYpfpKu$t``1i10019L0bK;6qnR zizT}kuFl!q!nkiXRb9>4s|;&g32vtx$VgZ& zwf@WflK*+%Bc`ruosX2|FR_tW(z5g7kCbX=e%ThLGYXQKTa(s%;hJ4Jchy=e+w9Eut5+|}LFx0&|JVt1c=(15>waY9mfgN}(}r)Vey&|ry`Hsm zXV2Jx?m11hp`q4w%~MZ2W<8rbt=u=Z6MKqB;b*js_qs*6{j~w)+$pVaN716XKwj?! zt8~ycRyMh?`iL8)@u6|_-)h}+<#&7u?Fi0Bco_dLnOKYTks(h)5lZz5AQr0JB;tGY z8M{j^WEX63xX=F6AOB~o-%x8^+q$fzro>W9i_(2)02B@egoHEhG9(S&Md0spn2d2x zl#SnuBSVKw6i@GnTfix5@+?LM$LxE5_5<6sX}A5|zxns9t1YZ;ccViU?b|XtQT3fF z#FZ+EeGDT0Dj=pKxOv(2`;LjJDmNs6hgj0Xo3xZF``+^&?}B*}ETf~xffF9t&YB@7 z4ss$6!tAAw6?~K>Jw)+d7P++de53o%I&jEL5LEGo8YJjM5H-D9FN|RZVAA8^w_mV# zUi$;PZR-ITPd!f_Q?v1QmsvBLMR9Mo8e4t~}Puf%gp@`qQf+J5xXal3fwwmtQg zP1dui)e?DWv2x03O&zjn&=TF?@LC=dG(jv(v; z=S7xkdtEWq+rsj8d-CajWNq!+C6_dY`qqsY ze4V)0X)T?g+CK;%Z=VRR6nV{n2)wl7tG3urhOMukdg_b55+DZRlHRgtOOIG~IctmQ zIUAWAv*V|Z+R4-J+wFn$F|pEV2fcD3 z(p@p-?nS^~E5v+!CuDpsnWThaQ;9@Yh6MDF=dvIrsZ`oVM#t>()oVV_ZA1FKFsU+l3f@3~ zL#o8PFMH4R+E2dsvORq8QTzSh`aNrEX!dWJ;24xJ%?j!2DT>|S3O)uAe>I>oQH~tp zVhrEQ=t^6PmiG>!fdhN4JcXBXb-SqOK zB=V6Ow8^d%SUOu~FZ?(d5zjuq%evZ|Em4@ULLZBd@|8?5zftX%6m`dR%>jQ4-idIpBrsIWf{Q!lxE$?8Vn^TPkmh zA8yv(xm^vj-YQczAtNq-_}Kv@OZr6#@2vd*AIFG@f%h(t(LF$q#vlLjAA95f^wUrK zM7zL~7$3r4=`mD+(VQ$!_~ts)6?@B!O^*8!(}P0;J}iPKOoNt6FS>C+8Z?0U%O!U1 z+*v<+qrI!$s%zH{`|=e`nPoqAy|h$yT~=X@(hHP}oiy2wn`7Ho&&z;HSS_F*l_xgF z)?lq(Jl+}!J|jS_eh@KMegHkKv2z{x zfIwXA6 ziJKGl{L^2vFFf+3m6eoBF%`SF9gGNQ2;MMwWJIhLBO-$cSzNUtOWN!OPG)zJavFBR%5zGwwrA#OD>lj&K093Uu>b*$_zvImghq`pJu&O60mdiCZ0DX`wthpi51TMNg7H#O zT4_acGAb&ntfs2gcYmdqj-}!*UNZhLUXmDk(A$kpy`X56Nev_rdW6s&{_~EqfA+u` z#UbYXxb9*-!f*73(?HkIvq?P^xDnysc^vdeC~Ht3K?m_2Bv@Ac#1ro6y>Xt5;>0xK zH+=iqrr9ThnCpXw4+J{rybz!Tq?=s75JF3&s?hUv_#Nw>RfrRaA(wXWBKfI~jEU*t zF4Q}}RIjV?_4^K7LhnR~NBHRQsTvV?Kn7;Zlf^18tZ>CHUAtm0{N#t$H`r^Xm1S1l zP-UgE_RDl%QdaE71WZR4B@D}Ml>llk7vel=>|u7gJuw{cE*9q&-8!aga`gI?jgL-P zL(MvS=)gm^fBRmmuWRydNf#34WH`Y^S|_4X#9HH;TQ9#h2)I$!7#+VyTOp80uE0H{ z93k}jKu}}mjp*fqdbhCBun|8}B&*=hKJP|^iW}efOlEG?7`Vb0byRlf!ZYY_{OIfU ztv~wz+T7R)t1O|J;f}@(oSyu|WA;5Oib|AAh4uE$*u<2n!OpYV zDrWYl<=acRy%7pLM>@fHEs`A|j#HzK!Qz{Q$r#RL^Q`}Rl^VqnYiTaF+8T!EQp#5O z>SHEYdOZu5Fr4A9^dC|Ypd>vfeCljys?7G>|26yi-}ndCvY}NnrkBbOzJV5Z7!gd& z1UKD&8b*ZUDBw3T=jGS4tc?5@UwqNtfB${^!WX_^d-v{l?==Rety@Mx#vMezf`pKl z&gFF2@LX3>4%x@3Al=2xq8kyJ`9=Hw55H$OZx7o;k3Q)8%oHYNB#5ueN=n>-VAoSt zgAv#W&UgAlCIl-<=`9=+(FO`Z6pDi#u?jKS)^0!J5YaLM>LRWy2QQ$0i;q@TScV+w z7CjGC;kooJbpx-d-lNz49Q&3lbyJX!VH{)(eIBHRY7EBfTE3(a?n!6$@#h=@y#VpO zU(q4D5P0AZBO?O1tH*&kOY!f>We^0OUrU|$Fl1oLI49ow63-Ystx)$Nt>~LL8Nhom zcn}CK@MVUUp3`Hp_BPmXqe$*;7aCoB!os+Vv~1sKFk!R3WP`mV(XTzyc_J=dQkj zlwl#Wfb|rfW4El-QmNe={OQSHNFxZblqtz^yOIe8 zL0&?NHqVA{wa8GMw(^RM(n(oKf;?4+Srjy9Y{>8sE(8a7R|O3Gv0|0-q8C?6Y&u(S zk3Ic&>^Fb!A6R`ugLpr5_hT)v<7ZG8e5l|pB(pOKZFAuk9G9si$4Y!|Lh$LK~a{3j<(S^VRe<*&AxyOx& z2tCTT4Fh9T zI6=hJFfeIm(|DKpF7P##H9k3uvDHnTo1-O4tyYpOwM2nD4Jm894U7=JElSWJL>AyG zp<(7=F@GEa{;J>uR9&TW%qDAaC$ofa?=HjL^>p_)AB0u*{ak|6fdBl4x~AC$f{-94 zG)hcxWE<^w-hA0!{LX*0hN=l`s7_l!F$Z{w))FuuPsBnO&4T?A>Huz`Uc#9$QkVtR z_V&>}yLofL_U);%4Xt(JJr2N;!J$0hJq*{l25H3MX>jw+T_hvRdd5-UM~~&%$jCK& z;83@%TPLAknhz6Vg#h1EePK>SoSG1ewXmRAM$TNe(oS5Qv--9}_S|p$J?rS);*;Mn z!ckf%leP9C>dsW_h$6WccYM5wm7%fV8uOmlhk)OgE?u!d{nJ0S-Me?&6Hh(qLky%7 z#?eV5$T|V}XG?o}8OJ6j?Ckk7cJAT@>l?UcLpO(fNq9lA%2Jr;j!1E7u?#%3zP>&m z&e*iM(`xD%5?c15j|}7bX&Kona^6zD>xqpU+HFgBw{7a`wyv%&tE#H@jqYF?bO$}2 zaXH<9@SAW6=IOm@_Su7&Um}R==5AQ6v%nlKMt=3@ch>hkby9R)g(#gEJRSGui2Fgx zeLiUU;SjYhq7)5xkT(a&N8uDWbkgaIeo@=Qp=CZ#3#U83B($CS){TC(ned+*x<=pB zs=BJm$2>XaHocVcwcU^mWx?*2;6n(@Lj@>d#|a1(`I$8$B3QLtBls|Ee^D7eEd3Zh z3pDs;z{@AO@N&qAVAn@%dFt8gS`qMe;Q;QByz{cX^ZK9KzOBW!y}Q~<3kQ@4JrNke z5@H%}@sJzD;u}9iZMD%}wv3cSDqCVN{_K6bd}Y+0`^paMXlqns#%54z;kk3IRt-?O&%ZI&o3mS-r1<%&!3Ac+Tza?(OJ;HpUJ<-o`5 ziHOqD`&b9WxB){$H|-Dp;17HT{}-SBlGWGO`*(3%9F8z~am5zW3pRA)hMm25RtCgr zyL9EE%`DDZNqMo=Hq=^eeT`MrR$6I!NmzwcTx5CaJbUf6*KBxX*bY5-pKaW@!4KcV z`N*WQHa9(IvkFtAlYUhH#OQ<%O>Nk)(RXjWZ~KGRy=k+YloHhwOP{5rT?dGtdqc%@XRteAjcx(%rNch>M%ZQ~Oo;?wy+Tj?0&3*PgM6)_^XQa{ooP+q2k zUWPgeCs^yd!Xm4!Znml#j{cVR5o^X(!I?e2y?BJnokm2!Gm83=;FmTcfZQZv#*3fj zzLVM_g5Ud?zDh-W;$UGhe0@<%_v?o>s{MN4vqFbY3bgN(K@9FfMmYs6U zp3@{Be&T!oxMQWP?H~y_EQUda>GGcxBkqD?Rqa(|_MOI(AD=Fd_nfAyPrC^Z-O6 zSwLofStA1Vh5-){$K&Zq{CjF@#{TPn{jcs6eEHe0cq30bZs>!Zh{2>jhHI`|yJ#=H z{1ZEO>5L`IleWPpXE$1+tjLljqG6G8l1#Jr3?<`q;QVKV%y=ZnOe_ zujr9Mu$)m|GT12k!rY>bkBs_$6ol5Y&Gv;Sp0oq|4p>Q|#Cs$BOq&QOa%c>pSq$i= zkjG~qa`1+KVupiozE*E)}aJ<84-X#h#eSo zk=mW5tS!t>*c-3?$c~?Q%a#}WyjKlyxuM-+N8KfTBOO6q1VdJIbY}v+666dSO6ND) zuAPVM(Z`>+>Y8TnEoE+^Z}`SstlX+}xsPQ(V4YhlzX(2+nu5vVunJx{6;wG8+)@&< zuFxS~&PogbWkR=NX>plftIMjauCmtZtJis0S5@bswxY&r$}6qBv`leJ!sMs|RS>NP z3Z1ktH2hCj(1`KUH7LB0w+A;I;_Lq(Vs`qx1I<`aTWdfg?N@>302MImqB zjN|J^l|W1h7zmZ1dm3#uP)vekD_MjXZ-Aw@2yPH;agrK&4mRSOy!0RLecUkn3AJkRmXgs@-4;m1k1ahgrDuV)6IWVlkQp(v`R#(jW(2~EC zUT~0dg^sMgc<1oDcILt<+qAXQo_h9i+jVfK)wkAK#kx`}s*o%vMVo>J%jV4~%uCOt zWq_ouAc6ZOheT=T6=Hlzxr=VTPY0)(7D;866j*Up(rTKjt#e109enhl-T%b>@(3sG z-6MzX#_-L+C#nw=F?gun^*WFvOr({`c1v zA}&@5J0iS(vxM!HYIIK4={q($>?pY+E;FZQJHW+q!wdw)M=~jxBSx zt$WJ0_e?9y`m=4_iymj&<|V~5>+W2!`I#Gb>t>(LO-)KoFdr#Q383u_x(qCGA>xHk z0H5ruj{pJLHE@QBI0@w&1P(B|$t=qLL>NM^mEaXwdY<5|H^5@r9B0^Pe25*Y-X9}^iR;6~gmtD8JC z1zqtzVnwh9Yeq84Djs&hAU2jvBU5CdGUhX9gLlN`*1Z>AB8L%pexFwDhjenQAeVOR zSJBv5Z*x+d%yn4pfpC0e{D^)mN#^jTn>UAizlXwNc}k+yd}dZ=SvV1gfP><6kuD$$ zKf!mYPre-yhLf@q(}~YRhJk(NJu1?xrDw$-+esz*a7E_H!x(W`!<(Us*eJltC#*52 z6o_OoDA9l-?+C5{Tp8#czeV^v5(aoj(sA&MZv2xFG3V7CpE|@ZJ_7)5x#7d$!{Xij z{G*1?Kg6u9(nvr;mGXaRs?=(GZv?$qg$1Qnm{+1Nn0%`kzUdZx72^AM{MLQw;ZPcC zr1AJymgx})jToyY;{6UV2)wC^dkScB9|tGb7_8lg2<$X)?k;oAjM3Iqa-BFKH`_ zD}L};Nnwcw(;_ks_5{#l2Z7Xq*RRlo;y4vpC_(V%^n;KRfTIS+b-AByGd1aC?QWI3K- z!4fuQB?U89Trh2kf=NphPFPXFn2d}GOXQEQhQj<&Js+3zqvv^(R-8X=MJtmsz-9bq z=d3__i#~QbDp_6|v{DX#x?cO`;8#{#=(ISXG(v!^fl1lA7thH(kSGubpqEn;p6kn;`Y_<12W?mfd_ z2Uw#F5E^L;FWwRVs9jJaqF-t8+ET&_R_O9)yfBPKI=gxj@psWP0rMa@_r>{7Jhs|? zW3VvhzKk-Acvb-;+D9C8=lx{3z@9kqh(l||xTcJd73Nq-==A}ClV^QNCoLZk$wh8G zk@jj@1m+v*y$`jhm1Pn$!z$jbuXnylmEwnz#W-MWAy*hSpDx4>Lx3=`nzJ{U2+6LK z7z(i-z*us{wA@)Z`W;9%S3mwx&ad*zka?7g=S+o@A$t@qNPUB5PL!vkYB zIy7Sw!?QMibIj%^7W^QtnNd9(nbhw&n;x67Ti1u}=JlI)?aDQ~aQ>1VKPJQB)i>|+VN)GnT?g)xDlaagADNZQQ7V+hQRH2hgIOC zzeePv-McpgiDO0tcT8_`cJkqPR&gy^QK1}-m6W7W^2$C-OIaDiqE<#cC}X)`iPM4Q zCJ>f5p;y{PaSB$l{*5g!dK&a>kfs<@j)ZWM&Os|6`2ml+K#%XJlaB~%((q|Rpq0ZK z)9C-Zh>JkRVO2K@Rt1y)c(M8tMJJ-sjf~>;->AMonmDXN^j&Tb1Re;<-z%)$e3n4O z{DmmpgDGNgulla=-zfyqrRP9Rr??;C@>$DjxL1}Xlrka;m^*-IUViFr{bt#nErlEmArO=?wT*znAq}R}b=@{it&$P>El` zVMS?k;Fh14Av{=;s+$NO-jfdVJIE6}=?PJg(cqh4=@BP@vI8suF<7GaOg{i42AJz!(BHqkbsh9|4J4vp4MR*Wa`E-aKk=zWT1c`qQ`V)t|g({TF*};Oc-Kk+Jd0OFy$$UVhcy ze(g;=^6n9P^Yypv?Kj`Cx8Hipjvqa46XWC7*w|=?4jr;*pMBQ4ySx1y2$KSLhz zJ&;=#AimGN=7zs|i0Jrf0>#J;KNnn5!mY#IzjNT%L7rbLxOP#oS0o4F3E(EZ_&pP? z%4|s!zWbSX6Gvm2_;Eb) z#|mzh;qgFVt}f`r)1Tg@YmRG6nO_KVdh> zn6`?~3~Ronw?an2@)8ZUt{Ajf`3SGxK=b8@|L8$b6C}RMiuYLuj=3vNkLE+<y5w4;`ejXb;@h$lviR{fn5cV>F3Y6cc3G~B28#m;!*ytr;VzmN88Z$Ftxh0j6 zCmQ?mKnyp<#}Fzgl6)patFjeq>TI+JA9=w3#&7au=USiGb8m+sd%N}{~0sGCbKWAV6 z`t$asFFs|vckQr>$}%e`W=)Og1G@PIR$NdfC7f@C!jE1hRjcYn`ayMtehERf<_4u+ zOP|E@$8#oS23H3yv!;&-_k`RCxe@&u$vorT1mU^*EC!ctuHom$hShK5bQNL;Bo4Xn z;-GJW*dbqpyWt+++?}qc{c8qL@N!UZVeU<+<>)d0`3(^<%2WE8(t85GsT^XyHu|IW)pNuW09lR&@- z7`eJC8UO8R@}4x;?){3Q;7VFb)2~W1KVNw$&pbCyA|)jSlBY%loWjToMgV!}hdh_% ztdtR&!5e11_k2Pg;NXp+2g(~_I7F9E6IP9g2)JjXIED<^4~E_d)6y~`wkwk$DVw`zWB`3_T^`vvFE@3HQTv+rhPml?^OyZ;t@canevpf`f6NtxGama<$o{KmDIT3jhhJ^)gXn{w-W$pSY!X1vr zhd~s_<3$kvUVZ)%!X5EHD*!HW1$-D@@jG}Rv}n0YI^(Yi-nzxQMzOI4a>T;6mq+#MnaKM^4XAsQ?qf}T@;0`jX6e#WZfqXvi?2Y})Q z!HppnQbOzv#l`^>*|^VUP}ovndd6=60)`?i>CN1&G>e;H3-1VW&YLh%9+8hd@K#>vx&yusDU zlFRrW@Rwr7->~$Ubd+8^zlVELi_<4+Fft+_O;!si_~Bf1O~yfZ2SL$Qg5zoXPn1)k zJY=rLUJfm8%Em5Yr zEy`P6=D8%LoRu2g-+h1fzgoccAALqo>|GNgUOATmibq6e5H#gN*XTKPBjK2pA3Um7D{; zk7X9{jivZhJnO5f6wmJ!oL`)G#B0)J{+;uPIF&Trkbu9$#}OtW<5=#)vsJvwTki-S z=MMgg_|&ff;y0fZg!JP5s!DJECaBSB9D2WL?;;l1o4;!uB4aH9fEr$(L)rU>41AO*RMK_Nqc zxeLUpuCKPnmIh0f7g<}^1}m>Am9ddg`WOqrU|5m@CapY3NcEDAc!i;%7-E^nTtF4L zQr0!z&m!I-ka+Q!Gd@PFBPe6oW}E`>p=vrGe$vl4#{AsJ70>YuN~o^&+ynP8V+=Gf1hsUuJFLi{ z7r=uWv@pj(FVwJc^+0121^+e*;Wvq3yeb}P32z=pS|SP&_zTj@3Cb^&LwFa=>9cvo zU|f^l40=dFANj!G@E`mirAk=|Ku$itVqo$jVR||o=?yq7BH%uk>2=u9*=C!zZnn(|$BectS0MNKCxEpaKOTIP@9vHh|IQ7c6}TIhK+?Dt05MB0(u&}5BHDBD z;l}uzoCJA-QR@vo&-H*_N_s$pqY?}QQa}ZGkAn&G*@G#%Rs8w;)fC|~0YQjhGF;8jGr$$mS>+S) zLlDp?P}c>Ex+kP_0^QvbdcWov6es~D<(-9k=9azU5Ew@B$&*qf9QjQ?9vANb%Y(rC zC@$lk%&lPFg&Q)swVn^}6_6>u1rNG13-xV*_>&OfnOB&Souqh0yjP%s#dD4Y%@s~a z7M$REBaqX8|P=Vv^3kH2kx`BwhdNNRvH>}y?5m+$dzeifeF@-n{Y44l@od( zfI`uAd}`8OdGi(f=3jo({^GlTX5WA5`*!Nw`*wYJz~)jjzMX$&iQPu^jc2KZ;t^d!Lvd$6W=z^Y8eN>uRc> z9Af6n4Q@q)9QTsQcqkx*kbg8h==Y$X6{3XD;S$t=2NXmc^c>;!-3H~c#esBJ6OMzx zZY;mv&u84JRzU8`%eo#STTW#SG~^FpnRQ}UQqpxaO3`-;ZFSF6aFI^0>Ay) z=NxEk=>3ji)jD@$LT|-%95UoysouC4N!XT_k{)9I6se+neX~p`w5;NBU6vw2V3+w$ zaS&8d*3-lH;7D)2Tk`K@xF~R+6%I>=Arp+5WsDjbr3xMoW%lTD~B*b+nJB8MNI4Uj_{g$xdZ_3U4zyCbO3&L1{`Bbzb*@lUz%W$4r6o_-2Cmxa zi>K`N^sw~~U$NJY{LH@bm;crN;s<|WFTee=oxOU-ZjIlvnT07!XBK_3=aRld9z9Wk z^Jm0Q-Uuk~c#n@vHlwQyMCm#>0lDWpt{7aaSKcXrcP!s=dDrTC4Z?RJE?0Or4*cnb zzw>V+fpDB-ymOz&@woCA{a$^351@{7!)Fnwht<^yPJ7DxLCk%gf{gzacLWsNNeJb} zI0)Xd;zpzh7t##;9ws~_^%+YxOO$HpF^q>aOmgn?6qI)uvJCG?+htkr*`by8&XaRz zNpVZ`bJVm$PN|L%P5mqmrNbP7X&tB)l!k`1qZ$^x&29?nk$cQXfRt}zIaW5K{RbS*qMIRdq3DNzflh{l>u=pQk8F10M0NBK5VCcGi=R4oAZ-4vSZY(e^J3l+;%Iw666ZXe{{Ksx&oH}*Nx93IqF~pIZ zR-E3i6zBbF26#-WjNFXwAehx&OCh#<&RaARk0TJ{6wE&9aH=)cl4g^2bLY_SO z0Cvn|3oVsS+WcaP&CZqD_)MjZPnX%mY-I@io~g32=?a_F{lrYUo>wT8*`(r65f+j* zy-;k^i^ck_Z|BPOj-YS!J6tRDE`*6F@N8nf+@_Szq|%$3Ew|aU(n%$4X0cR_Pq|Ic zmnyE(AW!0h!*sRd{k-V+YS1%xtH)?_2GPP`^s0S)Bm&i%LL0Dx4sNs$e21JqNMH@`9 z3_D*A0tQ7`b}pwzP6yw3`bvPba3BIN>A9YfANa<2`fo220TY!x3Gp-9TKH5~71*D7 zp0t6KOYY78Q=o4?HXvAW2oJ&~h7~Bh3RqI|5+I7M2=B5_Hu943kZO@_rDU8Dr4;lo zD+`?9W0Ff8exrMzkD%E428E%E-sxK=ujQ!%AQ)MT?!;%SPj={ImrKSv`HtT}ZC#x+ z`;lWut-7+>e*bU%zCHQ4l)&D-wtdHT>+0&V+qZA~k-``iEiEm+Bn=}Xo-2{f%I8#h zpo6C!)8kVBQi=mi2XJ5%wBk2edD*Dg=r?QH0dN6ge1bse8a-q3P5j({haY~;J!|Ci z3PBtqa5_;=(ajstNbtpL`K-y7@LnGrv`c4C+06KDn;IXt{+lW5zn->%>x(upn6bXW zwDk>T)nKHo@A`uE59&HNZ`X&^h}@X5-oZJ$rg!h39aX%v>80&z zZ}h$L>AxQE>=%yLgww#F@YS=S-f3Nz?d+8qD=2NXo^5-rrlvM*IHh{_O{;tYfp|Hv zNCZ*c-|;NKxaji23olqzRh2#T&_mw%19$WMN0EWhsHnk~9MQ{n@zMpKVA;{p;n$GH zip|bX+v)SCEFj& z^&jOO^FM|IIxEk0%Cb#gpSHz?McdrjZR_jTTb?{H6gK-`IfWpacxq;D+Fp9~MVnul zx6WtSq z$nxLe0XkTjC|%K1iL(tmRLa#WoNeOw%GYV?bpmf;_ys)_=L>FJ?|?XS>eFA{_5L#e zz5-tsjrCxeeO&YtQ^E~|r98_l&f3L` zhgH{8wy|x(%1Vp%PI){1)y61&vMAudNm3R4LY*n-_p<6}MfEr@!}juE(h8GRwxNB8 zl~*@Q2FRCqd=oxb0ui3k_rjMewF1^0)<@hGXDnhXf3>x7`{zTmdjCrw1GO4As7ZB?XY^H$cl>+R#cR< zL}Aj;{4Yrs`)}T(gp`^UDi3-$oso6r9qC}8@N7P{Xg~ewPwn6RyMJd}HgB>2`~U5q zSZ8~OFFivsWn~~7J$lss)xY{zJ~!eY{=@}|sIrO)LRU71TG->C_o2eq@Z&e{V&exOjTk&V80T{hc%~{_Y??D6 zNpy=%3z?{TTWD#$r_1lmi6izW|M6eg@U_=1X`{A~U#8DRJL=MfG+6px2VyFp`v7gF zU^t7yJDT&#ZFnSW3#nr9NXn|p@fH>=DZPR`P@fnt=oxmrWYvZ~Ry#+XvgHe?a-{f* zjZQSE5gW7mhGHu%mQf;Ql~DW@DI3n`pn(is?h!9=QNt3~T6t!{W|vBBYPs4Td-nJ2 zw}1a1x)GcdUqci3jL2W)2}VR1mnDrrFP_~AALl6Wj{-68j;m4q=AZwW^CR*_`p}AD ziW|3w?7#f!A6O=tv7Y_iR#I1L*=5l~N*HRQdsx~DP3Ypy!#T+?v{B?e8+iLA5qiVC;BO|GZElK~@U%jf}-N`)qzTWnX>ztG0dfE=%UKQ5orbwoWEid7QX- z-2V96e`v*ZMYiXGJ(jFW*kV?7Zl$VMDP`5=!rX$543FDDZ@&%o->`+5l(jXt+2;05 z*0Xt=wKlg{eO-f9lvYSbif4q6a*L?m+NwLZ!+>vuC z%4OTzY?^U+lY1IF-ha4blTHT*h(MVszw1cRB#%8+BJesEMsa*e{0FT{5SXj5Kak?9fmz=ZB#W4BMi-GLJJmHfVqGXa81h;*fpm(SB=` zk)Dr!Kv&Dr0WcT>%~jVj^th)tU|D>d)%~*MU}|pOzW;KSC6X=ng)jXNwtmB2vAOD1 zdW+$c)p~F`!!ZGf!;lETtc2Y~4b0)LI z3X3F)${QiUlE(8)r`Qxo_riB}cER3x`&~P6{C&Ic;C?%F-+fl*6R;pIl@rQzQg_6oGz01H9KPSN)CNzG0_N zzGIn%n^sacn)ts@|z_DlX3VF7v!XH6V)&jhL;g zE41p$Qa=&2l=#I(mJmNClx8xidrv2!ZQ~Qze{8c%SfJIkQX8WURRg23cW8O z4}FJ{FDonYiG_(op7PIEx&-|$;uNRc{l?!gD*ar6MihR5+DaZY&0 z(80|0SzDN$x6NH!tYKZF41zHBOhe!vr%+&riX-nIwafh%thv3(DjO?pMfJT>D8(u| zFRAfCPbXEUHS21usio1@x3*Y|!tBDd^$iZ#x$|f3>a}Y=v3q8I)>3k)3RItn`n<~i z+MBQ0k)!YVB<~{CXw6s z(5K!*8lw(#-niZP1ejR)!{Xu9xVt zQW<MW^yoQNW0 zZWdeUXSo-Dre)w{gvXp5^D6^&((#qnCE2g6sCB+z5^#`uFbM_$+E3VkpyL&Am8fKS zs^8*^AZ{wFmp9g(dJ9kaTn)JB9uPky!Jh{%fKepYb;rPI$}n+O-aOvG;!?_{W~S_N z-xa%j^^$~pz{Y1L#h5b^gnZvIj>%9(QmWpYP^yN`8~>QOLZcp-e#KAX%Vw5sQC2=% z<}$mRo#IMM%dNVi(ps7~So?-H+qj|KS{j?IqPSd&03m~^3oM;o@>Bc%?Z5x`cKGmN z`=9>NKeA`P{EU^$Kyb^PGo1wk0|WLi|K-2*375b7yT5Dy{vgf*j8p#U}LO(^!BSlmj~J=)14j&wlt{tgU|DHf=~s zVbAG#ptn!7lV=;bdrC)Mn4UPjln$w`h@SI{348BouZ-GJd+gCIwz0L|isW(Ri}%UH zhuie6FEiBF`SLpPguM}GNhqkT6xm{G$zFSF#3rY2*#3Q8*4|dFdQy21Ug6?h_l`b*2R9c?0s&D>jp@vbRo;Tk*Ob_VjbVZyoL3-V@>b8BmtEvGINsK7>Gcu)4hm z`+k}|5%6rdVlJ%skl|Zzy=6~45rz{J>VY02oY{n@v_X|v07wr&45D{m;5>@KSO zlv}hXYZ+%$vF{GKME_Er_&(*mjJHNd?4d^<@&mQ)Eo3^h*Z?pIHTPm!qa6r!`q>4-9zl;#-qqWom-@&0Ygm?kd77-!SX{{14_+FSXWna1JhscW z^sE=obKbSNS`SR*qMKD$mp|8T;+gPPw@I5y*V*@89<;^$9{b|6f8W|S_UJwG6=WzF z5tNAhBciys6>jf_Uxg7tqXPbC&z|+k&3pImwnrX$#EQ$Syyu3Yj&J?9Z`p-w7i{yc z&DPYu-trT&8`NMT{Jta{v=xx|Le&FZ!|C**{ph71*{zY=_TVEATSv!6OGp`pxkDwF7s@De!=DE;wx7q8+G>e=e}+o8#~lEh%So9aD&`*-7fnw&OiLtA6i92 zneDi5rxjGurL20PCjuK5P(2GfgZsVr^YDA`DRQ2D^($Yq2M<1IYrrv<=Skku~Y{6IG^{ zJ4b$E-9iII&j`?5kwG1^m2!;gpERPIaD^^F^z+vTQETS%DZ0hziOHZL z4l@n}UV3V7#&HcCd;kFb4M{{nRQj%7u$x1@mbbL1=g2qb(s7S+>mq#4LE7B zrwsfwFNgT$SKhGe*N3gSdCV%yD}6LYZlBYOwhKJMFeXWdVIqJ!RIZj+l<@lM8z{E* z&Gq)k!}nQpeXYv|yQmfvvhoalCey~#3SSEwkHgg&X+>>^a?Y+4TYYnfwRdf`QW=G= zx5OvNPT18FLn4=}av**fP|Sh5UN{k=`!!NwJ%uhs41>|BQTwy+{+XS=aM~I+G+6h} z9vKehmX{C$qA6WNK2}U4CT1dJ@@7^UED-umW#xv0GDcS18C2%X1E6qOF?Q;9p4}X{ zZdWh#T6%WDzWVfY_Qb=FTUB|D(kZlsg_QmHrI+kq|J#3KTeft2PsG-qZYd|FK$@yJ z(qT@-KmX_d+?5=2BL4Q@{@b#=OI*?T4tuIxelydb$J%yXMZ(D!gpk2MzCy!y=GSNm^bfh5Ylm{H?kzk@}@SM%cspVwbofl0>u#M%xvoz;yU@b<@vNBrb{l3?u84_SQ;6Y2NDkvLj~exuknxr zpRX+1k#~P!@4oV1ZQr&+84>cTieyB{&m0biz>S8Lz@;bH6T zo3Jl@vB$bQTP=~#!8xKOco2jFn85285%4hi1UyM6Uq-}|{qV=bHaga4Pd>5BI@_zo z+iCH-)C7uOGQ*NGUtz+Ph6x4Z_=NJYqutnCoxSktxTOpB*i&ErAFZuzOK8NtHu-fVK~sZ zVoYQfrNpIRQ>l#Iym`|N@#PyUcK`kN+oO*@YUNVq$!Mq}HU?R$`TCo$+h6|h+t%3D zU|l=9tW=IsUa``a%&~|U+Mw5z)han-M~V3eNhy4~gNvdbDS9Xt<`!-2_PF(3?zfrQ zDXXce@*&jqjZM~nZNQEmK5jMTwf5{6pR>I?_FJN$#19q2L4Y^F&6l&H#3IPKy#r!e zU}oS*F^T-##S3=n(j~igbJ}|^t7~hlX??4oa9UNva8fWHVrPl%zq9F-_fSmA;Ja~s z$a^lvZV!3Sa>vFt+p%qnPnfN$tPsA!Pf8n{;@*gOXyvm7=s$XwRdF%>gl{bWB3+O* z>C?fh7wyMC{8PI=ctY(QgJmx467jR?Gc`4{W$mc!Dx{nPEteTm*SsUoPCLN!(9a|f$u4>tenB$^21^LC| zz~F#ldxN|7H8v|J%Q|MOkTw9=lJ5M56>vtQ&1z>k~sf;XoV$ z9tJ5oli!7+8xDE~ak0X&$f#6b>#cV*m_$_eNOwfqNW23h)hr4fxf)7%@sgq43Fwq)enK^f@0R zV-xoNiPLuO!bR_iC@U+s#^x4lYFe-R3M-P4z}P#&?#fyX(2|6m?#Xc})`_tZ8y_8W zCB43-$#(D9Y2BMQc@G5&Gn~;70Q`e;S}V&(Ez_q3F%R5*jp1&5#OC@F3B9YH#uhsr zBLeTA`q4WYW0F}}u;Yh+Xz#uH2QnfGWJHu(GBNJo1S$Ru^7(^U7&KD{gbXYALKY}5g3!kPD5$aEkzjzuP*sbzU|MZVtp1%6k zuUhw}?ZQ(ESq3!oB95IrYHy!-*ZPMCEWNyF>(=DK zKqi0o+N(A>Id1z89Z`un#`H{_~X03c(g|8l|tgp73x@s#cmF!VZQb>M+r%0sc zR%tO$Om&Z3%5VXfxR(Q^%)N`gH2j=AT;gIJp;uPY%QkTJx}83L-g-9n*wasb)w)yP z3wrUwToihjgCkNTyu9j_Hb$^Y>qZ66)zrv!@5LP)=#xiXW?Q#zwLQCbSlfnHD`tg^ z=m5Up5r@wb`2Gzv{4girmmqTv^5*fxS7dmeKl7e_|J(n&^zBXCzi*w%*0SWHXq1;x zy~pvxel764(@74Y%MC`As%#-sXycP5_LCp?s;)cj$*=t#TQB+-Nk>3)dH~QpH2iG! z&T1HXCUKkb3qzYI-J<6cQ6g=<7OrKi!N#Xckt2C6Bj&@{LwV}Dlv=X+*#(;!pS2seM(m;**xt*%Hq>|Bdb+x8*Y;gj zS5qf+^QE9CeTV|ja$|7z;yv z=j>+xNo#G&w{_JLFcn|wUV*ov`;RoUo;G?@bWo$k1bJ3?EaxRYfq<&e<4n_=1FbNNhOdhH1l3lpEVv{pt@}OJg0jdGf zH{|PigM*X`OS;_5k)l~Pc^Hh_%Vb%SaZtECDITq`i@giNuhF_T9h7IYE_i`KG=&#T zrhgGR10BH##rHuY;?ta1Nn_6N8OoIG>BSoy9Pl0qy1^UQHCu6^JarmvC0F0nVC|jl zYS@~rRQx$RJtISS%(t7qadX&i-MnSDZVcP)pB6?p*ODj zqp?sOF!?r7A_IXTH$7wGVuhTA1vy7oFR8IfCZ*5n<^77!^c^_SE;-Lf`Zoq9*2M;t z*SE^R?`auNr%s>t4fdG#z_Rv3haQk8yxpp+nViR1ILXQ2RGRQ!p7bwEqv_HQo_f^7 zBRQcrxKL%Pm4UZmLz_civaPGlmoCn?%8ImARxQgQE|Q~ADLM@+PMhs{t!g{Sf%uKh4`(k zw!$iFs-+WZtV++SC4<$nTB=27PPMJ9t`e^m+3>Z)LUY7+@2s#6+0RwwvsPEVVpWxS zR=+M`Rn^N%16)@8Ie67oDOBg_KF<=>MT%1^Ihq&06$_WW*0{c%$`T!LUP6x=y7WVh z2uOF&iu4zB=U_?kLsc)~f)P;KSfm&NOR57JK+L3>xk+Cq&7R-0>3QoP>X#=yViO}1 zHme4Iabn)mGZ|Z&U$#t2M#h{J$BfdQS01wH@)pJT3d{PvtasVjB}-4H6fa}5lXEsX zGUM;?z-L3e-{De=coSB)jci(--UVZg7Hx7R5w|>h#FO(iafuh6kpd0Y`@#Eg@$KDd< z6{VJ-0cJ7`Oek8!N;OwVav{8p0Aorqeb_;Q$(mpc|Fr1R*LTxidiiyG@7PH-Sb4Vh z{)cVnzJs=|wattdt$LbUse0m`Y z=q(Uk!|9DOV%5Ntx*n(@xo!h}?_1M)2~_=x73WRMh!rVhtm5G_IjnFwtDr_DTVzE` z#cJS~WQ0%IMvMm(e9z>r+MnSw*WND?h zC{I9M=TeC(yU2R4j#*}Y%(^z#tD%rlke5oiQt8qP7B!_@H^$B?Jd^Ggp z8&QdIFk*{jkT6tZ8haH*3+7?`r_M4g}H!ok|no! zdGktZ!HU@00G&?TsdsrR)8d0!-A{=Yi#ETQZ>P@ASze;sx_3UHMqI5t!W=i+!mCgf zQmDKOrAsgqd8YrA4<)C(tn5nL`SZsmJA>BVzG&5zQ)=_(z25Vftg|AWB;1SS%u7;) zpY)~h6V;)H-YcGxT}l_)m22ZRvsh%EJ$tOSp+VoPzC^2_6Ilow16vCRYVLAH1%3G8Xy<`|VoaHR~VfvunEVyWS^{BuoP0tpB!+ZMJjU zPHW%LVYjZ|w&r!~WtHu=N_p<7#k3tiam-$QC>mY7oteY z>$&Hiv;F(`dzT_Jjxiumpm14IaFe#4=iFiU9D+4^qyUG-PMFrv)?;V{W ztgv?+3n0ySz#%+ed&jMB{DU&23}RR>lY;+7`AG3(ymv;8&!Vo2QXI^|N$We}=atKe zRkNFAM!h_B-7o3BfECav09S^t&MhH2T)%SGZd^NNZA}HXt}4%xh3rnMWJEXwi|#vG z-+(D~3NFwQg+-8OwOnkymu}hE=)7&-(k#PL4VTZb<16?7yakr@L4PSArR0VSbx4qc!N&7*Ir#0liPkcv(rQ#M_s>Wn z*U5<3BhQ1L2(XXp!V4(U@{ykLoKl5%Ji8MYXlFHd{#&ONqk`%dfb>bBOV7OShQvy!3`afcdX83<{g&!t+G z#~kp7_ro2$9B>!CgbO_atW040?#|9G+rE9f8h}dwW^{DKZr->l-83XeXT;dR%G*4N zMS^{9WZ3$U7GlpBgzhOLYQQ^+u=13TFOe6ps){b|vShh*bS>;{#_iok>~mmCJVZ@?}el29;ISRx3V2zq0(3 zw0s$;z|qt-)(#FNofD`V2DF~3qifZzVa zt!;9^FbVR(_nsOJtz-^^Wn|>0T|9T(YATkjtF_ch6P$e`c}K~TzJ3+R2+do;fP|l@ zSnv+{3VYGK*k^^P|M!dsFL+9y^r80JtV!Bl|H-LH)FGlrPkHG&(<|; z5YCa#^1pz`Cp01gvQ#__1To?YfT_4gP#7M-3Lcl$j1f^)Uu7*F8?2$F!8UemwC0xe z)=*z3OM0D^s__LDvS~}D)4pVLW?|N*=Vp9q=%f_t^!%*N>e<}lJYycNjFY9sRxQi6 zxwTnF%0}C=eXDKTwaqrRZLqr9bw1{K?(}(E->}}7YB63sa(l#H`Pr*>_S_lk+0t!2 zJw0w!a~$!Fp_{(N^^bo1W3QAiKl6+|{P4rBFlp2&D{<+KV6x?zGiO|YcJJQp3N4wC zVvut132-WpD{U&tQ;V4)0vL%)#7xA4em+j624^uPYgUYY`s{i8^S}6x-4xyrKK!Wd zma?o;+4O}|W=f6+#V`)yQ4=jhFwc2frF=kPx*LLckK?gYUrw z<*w%v%9Z6(>%BZ;6Jv|A+*_msWZ)G@p+GQM?W6z!y}B@B5LoJ5=}_in3AkCQ8xg&i z=e+y3qrJsyWkhhYMi^Ytr*2Gv7lsA+U@(GP^d99`DB}SmqHo$VdFyPWlt5KYy|U+9 z;(AQ~NB)88p^W}2{C5ZZ(ndt?WAudQNqlk+jS-Xja0E8BZnBC}d8;aluS!t^LmW2e zfoB;$oSB)n(XnwG9=T-$azq9O2kfTg_m<>j^yau-klc)oj$3J2srPz}jg9(#4D3g7 zTVZ-~+J{9J=R(8IP$?c(0Y+vLURDrs983L2?@2HobT2x_<#Cx+aD$?_#D^R@x;m|^ zy~El!cDS*_1UZ(XkKY>i4X5U&7Hv_1VHCXkqWpwA9tnnORSTR@ihh7f?)@q4AlEcc zMOI!?Y0H^ByMFCDj-zx>tLRnkcmiI(;03qomN^pybi`sRExmQle)_XltfaicYU`V9 zT)44JxwfuejaX8~3OZelqyngqxeZ!UsK1odH?N62QC>@?}^wo8f$uCDp;G z9Xoo|b$VOt26q}qMgI!rT~k}5yb6^Lk|=voMhbc+^gvQxWgc5yqK7x^Ey+xE*sjP40k6*P1CbFC={+)w%J>P52tdh1iLfM4^oZsP3_);!F}iE zE_qa4Do~eoZR)gk1>S9F-(c%EG+TXRgOyj6T49pLUN)6H(7DNZJ9p~5wKTL^PgjqP z43DVMIUy_jm`%$#sZztqL?FginFz!dvrL+7YHqeKed$Z~-~$hM?*j}(g|h65o$#1c z$)qb3>iP5MT`@7-!H#kW7uDiQ$SuiS;5Rg)8p1U|U{V+}{3`OhYkh3UIIT>Hl~Fl7koz0d_{_eMrOOidQH%X~)~I(iG+| zbKn^YOc9AgbkS1wu{R_%r1SEoUVG&w`_T{nL`KBBcJ1;TcJ$uD)k|SB~oa5$nBn+OAwV?-K>{r5MZ0OJs2LS%2?gYiL{&KbDI6vqBHr zfhc?qt_sRGcmuo(ip|U7N}^0gjf{xVMeFEUug0G}M^av97fuWf#dq3y8oGJ|sh_Uk z5=ybyvP&`|E-lE=nY50M^;T0=BJZ0e%al9NDuCz-U;3S{N;4=XJt>&8)KaNk>|2l# z(IB}wXcaY0&Tqt3M8Ahu_#UiQ4L5M(;a%`ajEH+AnHG zj2_}-Rh9*tS)8?-BRA}7{}nqar{vhlBlfQJ%F)xu?AVzTcKp;aJ9qJ{^$uLN!T#%Z z_QF}aIeNp=D`~qme$%dAziOBIF4*Y{C+*y&b9Uj%IlJ6@$@*n{+`c_*6O$7%;L<)J zGEbHzyWwV8J)$~tG{9GA$KCJXOd}dMq?DVzXu|J2?=5MnZ?w*~cH7#s!?t#9v%0Ev zR4 zkS>QPJ(pF#;de+YdLh6dkRh%@xNOAq=39WMk{5UY6%b@j7^rPy70m6CVY_(YggpMN zb+nXNnfF96JPQ!lFZvEa=LhM7kKX7vlE=KJ*~LOTeQrdCXp?pCKBRi6QXPmcUIq*X z8t@?FR1HZ{5B?WtqUZB+mad$6N5=M$b!;rKnhNog=S3vw<&hm223clVvLh1G4hb*H zkS&p}#F;Nw+j~Im>qa@P?8vD&=noRmf4_tgp}bT$FQ6c{Oqu-06)$dKZeDmT+vMD& z9Xoy0D%Mq5U30B173w=IAO^g^M76`Nbu^5u)vkJRMtjrk6J@RgKh5KY#SxuOgzN1 zr9zsUn(e@W1NP9v5BVe`b`a#S9?D7CE?&IoCwo#}mUXfPHoI9e`LbKa55poXt%Ol% z9C8&Z5a<=cYQ=*!v_!1+kT5UZF(FUsM=!o)*Kgjo`yYHnbSbfmGA1Tv#8gSp%PZuK z=sQ0LLE!ef$_^(v!-If;^x}E~=xU@}lznPYcnb^jHZwaTW9Xv2clf9^u5Y#n?>l6Z z)6??kZ(4nQz138+G*B2^&cW2oG@C-p09*!4`i4mbY1+0Dl7D}j}hqu+{~&Q#fz zYiUbci?w4!)Hdq5;xNW49D?T+6t62H?qUTKU2@^mG$MFTuLykU(e)p^J2^RN*RJ+T zjvD0o)Qabpd?IS!aK9Zs{jR<8&a3wJvA68vwF@>fb<38duuIpKS>1+O+pxLSI=61L z_MSFt-?-5hR^}{GRb;yk?X;~2w^-YjR$JfQY)#O#MGa+Tku5II+t|#gU6$d&uDE?e zS8Yg!2i|=`dMKGF7B4cN23$pDX44B_5^^J$3!?nUOHE|RhoL$qJL$@> zOLaw^jYvNX4c?H9)>*yu8ivR!9z^wF2Q?PLk5yf7y?xmF6o2>rL)Nmf(|a{**ENXt zQ+64{THnxsv3svi$fS;xKN<@DV3WJJj;TN7+D9UYyOhy4410vl{N8={UAsP{ykw}b zdabImTKc3>b-khnO2$t?!Wx?zyv!Io$wZ;vA?tLxBYPwaEy-Vj@(3F@x)oM9`Wc!K z2b6fsFDk?7jJ(nu7Q|Cslu(;8-?0CSctI)YB z7md3c5P|O0kAv5NV;KF^z3^X{pSR2B-m#R7h>dLpqB$=qFFg#Bv*>%iB8Y@Iq)fUANPb8t)PIVj*#6to@OO9z zf#G2Uto*Czdu{!?^|o)%$2AtuTZ=en;4(4_arDPwB2{`K3|qY15Ks*_X=F&0K_zke{&D; zGk%s@F0|KPd(%#wJY(HGTWsHfL$*N%57mt!F*Y$_C1qt+DWSv&A`QlGQ6P}iy?Su) z)MCJs33;qY*tt?J1nA_+Q+D*&abME)&_j<{_vS7uE3HstcFDW{H*RRPaw!#kxr!tA z{0|^o?ltmGfj_aM{s$iydt!N97ani$LLEDUha1GppRFvVea-`Q2zkQzWeCXr{Irz+ zts6EycFnF2zG=-3)3$qiiIjA{)mEl$bCd3yE3JKfg>CMvw#}PrY)enGZSLt(1|>E= zxomA6yY2pmzF-aVcyHXiXg6d;w6^A1bzPp7Di-#;dp+=(&+t;-;0CiY7lI{R1yVk; zG%jrmXMAH5(b4@LsA$ zHF!xG6K;@8L35288m=*|ASKV@#yft2BjX|ZV-8M{8qSLHYTMMYNj%x(LlMk98XFt8 z3+K=KoUMlSwQ5jHdD$9rri1*x(K)3EvC=-6g3{eT6I^{b$a@*&+ zHOXMasiQ4mLb$S$9te7jw4fv)Qs_PyH2|iS$55W2!*le6PYXd%9dzZ^c|A_a$)Rtk zLwaE`I^sNt9x~2^L$h+`5 z=;rx(InHO^vvhV?Mnuw>o`bBf$UzA`(`Z|Lr?NX~^q&viLOVAi)SgQ}Wo1NMy(R~9 zzDh>KeYUQ#O>xN&Dg?PDfl#VX06}0xL{*$`L!Cvi*q~|Iu3hW*Wsont@S+s+a1=+4 zu#gWMC#Cg_U(6`wc`!hjKM~Rn1*5RUbIVlB3Zn)>=_2Lh8zQMifk1x13prrSNEv}2 z0VPEjC0D#|CM`Lm>mAC04N*`y%yVGVC?-$F;PHhoxB4_>$TPo7rAHQLslyR4|B)S8t3<4-(g4?p^d zO-@YPiK8d1x3|~VyF;5;DaZJRhB^jSx)7qvjsu^d0dX0cIeg@(8xS?Mb@tH1k67!5 zHZf75b!^^jon1X*q<*__`HC&3!&bMk(Gvh>{vd?j2l0r%rw3s{MGs4Ro%6tr*}Xna zhRR4G!LkgF5xa6_)avWH?a9ZVw=J7@SZPv9m+9*% z(T>S@GT<-@*#DkhGuCDMa!miN;L9}4vPv%_BO)s!fUZsAWfgx3qXQi1Dpc^H9Y4?r z{#N-BXn#f=W_XKVs2sfu@zd^olWxc>Tya3NN}H7>OVR~?1rG&!ayW#?@zihVit=+s zK|MIV^*jf{&zy(x?T4}F$AFKL1bhS^A`|c;GD%lDGC4LnshoH%ad6Qhn>%=3(w#}MlgxEzS%psV0={#Ol2 zo>iC3ap1k`jRrZPmM)o0T5VOW?cTA=H^Y1C(I>4&znSykM?Pm1Ijsgu;=MSPvXSef zcI`r+U6E6C?A_yb^4MuRdibc#OilY{RMj%>@a+AhPgQ1&4>)q)*EeAAzH?Y~$hVe_ z9rpiY@4v(Qx~@B4c%kw)WvEw8jr+6~SOfs2yGMV;Fzkj^X zJNHfAB$G*;UObMgEXz%{Bulc2)f;>7Bme>gLG<44_p{D#kw*w6OC%*rzFFYrIlp~& zS$pl(b~*c$RnM_;k&U;vBxu&y*l3&<+90F8fB$~lvGWx_x1%60aT+KCoy`>;!xeo( z`LvcZJ(%6>=xDcRo_WUF>;8hGQ~L!J|j7;KG&5)zt{sX$wo)X;|#o@cua*fN_Z>A%TKw)0MkBB z*dwr%K_2(~FG5mA5>z+6K<7zlUj-Sw+%uLVc1R-&mMs>yEY-+Q0+^Stkpv&-$+hb? zZ}pL2Yiq0Rc=<&;C!tL;i*Bsx&gxZfcc#yiJqfUyD5y~i8$+Le{(0Y7e$y?t`RR0s za9)0~8}MZ-S88NDV+RjO(0d2eZ^_2ZfCTFV((&-N{}w|guWw>Bb%m1!K?;P}<;ZL0 zEH&ZpuN#q+IH}?BaU1FHwbm0y?Aa&ow|l?xNA|sM|AF=QJf+dxsD1E#%Wcz|W-F6o z&YLK({K-=3g))_HDCqUA@&ucQ_nHGeuS~Ehloa z&td$AioH!}T`Yg1~U@f4Yq#Uo$%S>+HPM{6QO2{5E z#$F?4I!1@atWO;_*gs;YPn@|ZYd9EyB-iuf5c`Y(#$9c8MJt(R34-=K?UI2Tt3rXLj4;-+RIy zxc5PO{J|$|=kvR)w`;&EiYsi>+RgUykN%AD1>*PKeuvfYc7>c6XhU6zVNQ;~Kd}Fh z9Xfi{n&vOC#-;_5Nb!u?j-f-wr^IVG66hA3xt%+A+MZpz-I1WZzBv;;C|W~z|N5+e zx1y+n_VT70hgKbv)3t@|XW%JtG! zG<_)gKzBaJ&S7WAPaM?%_=KF2aoHr~>ym6F{Y#%F1A$~Yp58?+)o;kD9w)O6m--+5 z759YX2<%LdLy)wO0S0m8WaA?P0LoQ435hd68X^)Lk}{Hfx=|oKbt|{@35VhFT6v;J znQc)$GYrx=Hz(@6RE8g??Kp{2YH#8mgsusCB)Kw5+ksS91R+FRnQ}(Z*j)J7V~^Pb z4?JLO&_fte2w{Lh41dWPd;(@bmLNVxE_o*LcBjG-&g7wO*dEeS7Yx92>h{fd9)j9> zCc=3>rQtd)ql_?xKrs>kcphmYJ!J!)PvwJSqycb<*g(l9p*!!q)86x*_t*_L+~Bhi zi~u5?8sT(~vQ>&-aeSTZYDbWt@{@Z7;ofthPsV+pPw`dP)<^+yc7+5)4g~d64`)imd=p4ooA5#!_~@Fid8v=0b_K{hUeHj z#TR;a@7-+&j~ukF?sL|cF>LzhdHt1YF%o+h*ys&YWr;@lSsIy(uAJ?WBic@nh$Eq; z?hE8n_=mNmPp8MG{p|q;KAcF)cXb%yvef4|2w&<;%N4XE-sM`q({jNx_!w1;u~G?& z^3F=GvX!_K42HZ7lUa7+hzyvVrvCGTc5L5Cd*zv3_UvOX*q#>;T6bH&6=zr2+9m7l z?)QJpe(e{3)qdfVpSF*G_+xg{_1kT!1{-`%oKrhBXm?yf?!k9-blgrzS8#yPf`yB% zKr)|T3`Z*38doHodlli%2D7s)M>Q{6XlvH5v*NN+ z4M^ZKwN|><-wpyVWhg2swt3A9?E-Jbb#~a8@26ww28U!=bQ@JroPI$mRddlkjE(f$ zsgsB8{`>Ec#FQ!4LM=>i-04SJdeHu|LN&*>D+Vn-~->bM}P1g zJAQPxjSdf?!aWVAKspa#yqOWvmGbFsr-K`6$s~5iNAI+knlRjn6VH`^V-FVbJ1c8U z_Gv^mi8%3z>>_88&}Z09+7sY)>F-sbI%k9D6%LRfdDRvN5L&4I91W85g@3Lnnk(Ms z{1NHL={(WFgW?r(^qkkzr=>TbkJHm>kl>L3xWEHf2yTp|xHz*Nq+uY85@#pCEKSYx z-3YQ7l1`f#6C$dcw+NUuVA_piIB^UJJWG_EZU}-znaOXGAouk2iIJ1h3MZxUEqzXK z^Hby)iLqvf(CR0S20N?0Q`MK`VMKx;Tm)5di5MzE96|7PN43r4E1eQUF zqdag1r_l*{(xit@ps0AfTWn+ENC-0JCrr&%Hbl+>uW~}qS}BF9su~$~Mhh+yK3agO zOG=Au(}s<aJu@|EmOrj~&x_!r>ic{f94@d!hX!OAdTr^78*KCT z8?C6iz-IDCZCuXAxNs47+URt?4NVl2XFl-UeBjh#>^DXMUj5X zArV<}8A+&&TsauolX5a9#AoDHy-8iNL|h$D91U=hqoFW7A;%M^L%a{~G0o~5IO$$G z&SyQ;zpqk4^aVlb-X;is0T<*hoCscP>>uj!Q*Db%^L@XmYWzUQD#4}IWfkXRk z-@ZLQay)moQ%0cQPpcgn6r;(}nvuLC|NgaK50lPU(<9Tqr(kqo%+Fakb^Mfp2l(S zAG49(QM+)q$GTcCSW$MVZCbs>KKYTKv)}&bzhnR9cmA9G`Y-*4ec<-HY}Mj5*4!}P z%1ijJhwzk9Cg4N`e&8IlROh<7ZD^Py?iX0?oVhO7n1p1{QKGlN4LwGC*;{e@9d{@) z%V#87PoH$1l!u;`KEe1h13_Nsg1*6lzyaf}HNM2kn`-bL`UYS_W~BGcnz9AFQOCI; z?Hy;me1Jh=(42D(X5{E0OF8a@s~^V3?Bvk{_Q(U@wC8^Gpq)6j$9lWlZE|#2bk8^% zYMlpY?*T(Ed6Jy?Y=Qd&>Lr|(Nojzwerr2<(4PL$_wDf?e#<&eAGfg~;Uiq23E=(D zZy8QnPTypVpK!Stk#Oqn2CC(pq3;s=J>XnD86+i(y zLH1YAN?ro*N7t#sRei*7`rcht{iXumdYRPVV$%0=$%d*ZmOoh%CGhcC4mX?Dz+*}w z8|NkuZJjJd<4h#&Q$p&8rdPVLGC`0l)9=X=03!#c*b%->8?N&WH7!e9Y~I{{!wE(ZTw|k)S}QF;^vVuT}R4KF?j&U8oWqh>NSg`ji1b zPhRnKA60Y4KBE`DUOF~D=AGvoEmba`E~iX(E^|%t(IJ_86~JB#_9vWa@AAW|s_W+H z8Eh(s_j*Yu1*t5B1%)=JwoW6cKHm&AIyP$MWfe{%91olZgdM!`(#g;3Kdg5?_0&_o zKZTDBad=g3E^h$P3klOPMT`Pvqf?2Tq@zcU$S_aX!i5X1yqq&Vgn+jg)S;X1MN^+z z);*|>j4-{Zw(HT*$bfj`j13MATR~BY@<0uMqKaeRHqGNtv_cAf2|uNB3HOq1s(sU?aml);DA}pwK@w zqx&rDmw(bfm|O>l8=SUYg`weW8yL*Bp1v$QbfnYHT^O;dx%Hwi?|zS4|A1-a%qMzt ztao6>dWPh5538)<8I?0_eTo|x%2OG6c42Tzzb9<}p>CxeGCq35-jG2V$3dkZ8qTu8 zQOShL>=$mmjt6)VWOy_8Lz5-e)thUFjw)f!9DhZtyaHa5p`q&>=(D8%5{N!UsxI+( zz?Gg8aXE-Ii5^jHK07$${KC}ofdl*Gpch$Xb*aBGQBhZJOV=#5g3>|_D7t0TTCJ_M z&CZ_fvNn#g=E&>LF6;1Pug_cO>25oC;E>`v{Z(%^6?VwU?$C($^eGNhJMOPC<3JEN z>~45u*kAdcuK`e9(OfIj0BH59)mA4NV|fIb<3Ob3l~4WfCL}VDK%yfYGSNr|f5|`o z)BglFcd#C^nK(R1-~2`y;B^~q%a(0+^Y)wU<{NIdwX4=y^W6DTORl* z2e|mjlq$#1Ch+c49Ac+?WSTu0FYnxKN98cBTBEWSFVzDECb=%_U<}$mQ^}1&zH|%U z<6(IRoz4cy%8GKQNv`?|T1-pNaX%`$;b`DMz#DuHU>@JdP+o8VT#rgAQ2dOpI4#`s zig{=I8S6RUWs91dMC&Typ$CGT>>?WK_6%|VV zkcdVK5)!D-GfSPWu)O{1FB}uKcS3`$q2WRebcRIJMq9FOlk+0E!}b7~A-8_B#7TTS zL-)G7Al)@+M|Qs~+O^uEMfp}y2Evn0f3M#EBP94_I6(inOMc;Sj7=6K=R}N_*uo_o zHr1puRKMEItIUqSQeUwX;etTp`5=%uD%s)B)YnPdv}vQ=xcwGuX<4KZd$AO7uf4qE zMJv*Mm6*hL{L0w)1`i>W7=CpSokD;hv_2VC?qHd zp|U9t-gvG&IvGWE%y8d`9oTWe=FORJo7ZplcekBh5I%8HMf{|jU7;l;rAI-|erX=I0lQIdMKNDGtiT+XVcl*C7KS3-I_8Pgqt# zv8|8-V>6OlWIBxeN=trDw?mLiM3Xsl>Rj<2JakBmGa+M9t~&EnpM+120A4R%G*iTE z7GuqlSE{z(xZRd5Tj9?ZG!l09tayM;@;MkO7CcA{B*^+KG0BwlO=_I%Y=|PAdNJnSQ%4B!w_hXy^N;>~!0Zoj5aM zC(n#4J!K8s$+IK6j@rrA5#=9NSxMgGD(hq$aRYWt^>+15+URtVb)HvV;@gMp=$T=q zi?YT%oB$u<{F;Pgr-$v>iBW4mHzVO+Y0Fk@mV!^tU7)>*@ut7HDk1|(k6!wnhBxO# zpt#_RS3e5~=K;VG_tCR>h8WH)IeO@*oWOCbt*f?UCy)4vU@e>(BmU#{&4&6$tC3OV zO^1omDLGlAG9H66Mm^TiDo5^gmoN1VXaF**kt<8BXWQDW`+}Uq{$76z1X{3^S6^Re zOO`LSwd>Z~vgOPDyE0QfQ#LU^X)TLd+=<}a1P(=lR>^6q!qm~{zl8tObPSPjP+Dl} z`i!3_Z`$v1x|NUnbh*`w`K!t*tw3^`Dy5i{n9CkXZ&CXkUIY6PY9D zt4_2*hB;G({x2&lRsZwOpho=C3oHRKC}CN&P=lB4+mnsi{vD7Eosxb=ze{5ihptha zAETWo$68L|*|yWRU|y3oHP-uVhcAON!={lgRF!#AKldu#KKtQ=U$p+7!*Z_bY~}Jf zHrOvGr2DiSfg^VO=ngx6WQQHgT#p~-`by@yE4eD1J}#%~&%ZHbSQT4Ocr$#jj788ySPT0Xchiy^gB0mzhNXFTPj(kb~B@`4OppFyL zB_dt|gn_h4UFiU@V>{Z!u3ttwU;E})?S&U#^waA&1cr~&ke*kr{Ik6>fbJPGtwp)| zL%`0RJ8zFY{n*pCwcrBLK417s zDado@oo?RTD9Y8yx4coK8wpG4V#_OCVtFNmrAgqo;`2*dEWcc#Y>5?=FY_z$h1E-} ztZtb#F5YCN)r+m9s>Sk(H6klk+R~+#Te?u8+4JU7M(HAzwNT2J>k?g6PT2yLHQ#EQ zHd=l28mnzsV}(jzSgv}@mn8Yhz-6)Jl&YL!@Z@)bUw+9l#R-p!W-F~&Y)uQ++v+vf zTjM;HEr--!OM{PaVBvA^YasdG`w__eOv9UVB3`E;Ad|;s*!f@gIDh(0{!>TJ*vXS8 z{T!CA^XDW3SynT*)=#MA?1t*fDnH7;vA)S#7B90EOIO+oIm8R+FLdKuS29Nr6@jI4mq$HZif&t={S^@zY0lS z-TUi;x=&7)#4Py(Wg;KHvNVz$w`ZPx-u1|$CCih6iR)O}Lm-nh4DF)|{rRRr(Sg5; zstOtQT*?1&>pFMNkH;@#gR2na^8n93|ALii)O^FX8+}<6g!A$=P{cl{Oz&2l8vQv+ z-XP&EUCI82QH`A2&z!cob#?AY6iBydtZVF*M^`89)9Cu(Gxqf3U$OI@FWa`w^|oQn z966E`8l-f{R-Ct~@h%%3YSpz<`s$QTjGR$8W#hVz%Rv|)Ijt*kr)_NHgonxTQ#L(* zTJ?0Q{z+TCs>YTqR@){tI5=^{x_bs?%;(CM)re#out;Y>Tt1YCU%~6zJzec~bieFd z`Lr!uSYi1EatTDc8R=)_dpa!!^X0uC#}W@B7H+8aVW-?6Bc5 zE&C|&p3Zf2*>}EkzrRMgYRyVtlVWX$-K5xIC&Cm>Vz0G~mkQ_gC|(IXb@;S>{~O=8 z+in28KtjK`!#@3qe`0fL>zy%OiDqU30+RL1gh>7x&1E4W9FQo2X`7In9L@~%z-#}D zFaD|ThF;jb$UgGHyKT#+ZC1r5Q=|xChXE-H@_RX)3O6kyzt{fe?>=u^Z~Ks~-LPJ5 zg%NR@NGC=>sN^iE`-MK5MRb!RLi~l^9{-ldVb#B4$ztCbjiP6n=|wp-Yj zbETQ*&PmdT{;HFJb}*{JVe#G4`YlS@$)kttv4Pf$sm`_K zOXp~mS*)^|ap-jgW9obof*XH3J`VBxN$OI?32=>qQt;+J1s)gzuYMlY0E~>>kSUU~xSyOHx9@-RA$#P9Kd^Pz zt+Lkhr!1>9%a(6mA>C1&lrMQ;)8x4NQwT`!CG8m-9k#QlTkXWlZT8Bpoz~OWZ4C?N z+P0gw*~)b*thiK;6r0J^7tk`P6S>l8Uwy(Te`H#jbJ0BYkB@e=wVyEtBX`B`!-hBPRemUD`Hh|tnwLwzOo+$-(2 zzqI7Q+Um1)M&xg%tQb*jy1CmT>x86VzZDWNr?EyPW|IPF;b$V>ZBev zWJ*?SWN6TipEzc9wY9cj;e4NdMe(Q?ybZoVQh5Q?+w{Eue0UB{FR2shUNP4bJ z{lb&E8ZqnE`3$<{sm$b=dQ9c;ak%7=wF1#OS1iwlIOUPhGU~v%qXTm^8snr?KDNZD zE=LN4W98X+$S26T_fuZUFS%h)0rxqIW1V_h$~;T?m@!JM1I8XhtAJcy?6;)Hl77?= zE`Z0s8m??60=P_J0r3c4%9O(h`$BJ@y}0v58=V}sp{W7Ot;)B`rb^2y5~gwjkWcor z%&^N`O`Mb?Xq*)x1&O0}=*46HX2I$9GgehwVe2-owz>1?+H~%ucw{7TByy!+WJK5> zg+5>@m8o9zPk-xx@v$tH7i!c_j+*w7J<2%R8@OL=-o=~b)&qJ@wD550xl>y|{i|wK8KGS^zRNGI(XR1E6VKVTW+;Qix+!- z_|j)g<*@ob5)cu=g#i5>o?7@wJR#Dx_rX;+EP2cqq{C92>Oa0%kh-P#dq8OXrLas;?*Ye8l&o^$nZtJKQS0Za$;TezQCo_T(%W zwMEVlUqjvd@(x|QWP66KtE=1E+6S#wI_Px!sO;mIooOAhHia|o1BxH^(AGBL&rY8i zvQw?n*{4Qq*Zv_5jLIapMZN@g^3+K?b?UgCrw0PQT=CBfBTf`2Zv!heRjyNr*u8lG3ZOI;CjjfpMmIA z9YePFK#%yc&Q`42YPEA3)xXkD;+bUG8Un~Ul$QesT@m0-!WfS{@`ye4)RT7i-FN#J z?EE!EE0H2J8<()pWKY<>6Z`Ce#~-jGt;emnnvZ75lPoW_as@WvBVq5q|NA}@v24Xs%f@4*Y|XKZraeuET$ZH=St zJAGAdBIGzKiBkvQNHF^$6(qcsc4}M#a?ZZ@?QdxWu+;9l^Zjz>%0Zu}q8YC%sU5UM z!0g2N6TSAsNAI;0r=GA4>kDnonj)(x&9#m*y>cMbzZ%U{R8Lu1^|0mVN>XLW8L{*F zA|s)RY#ELmDZ9x6KcJ?!e@G*$32W=f5pz%aW}ni^CaZ5+YRwBa+uVkgR$f_d2aZ2& z_k80oZLD`d4%3J2w)cF(%4?d$Tk^uyr)(v2zLMsqowS{eTU;51%t$Ha${0;$Co?TT zlIjwKO-TA(qYYh2HOZ?`GHUQnGA&3w$=eeSDt}tC;G8AC^jgL1xXlP-GCjvGQyDbn zV}A-WRJl0?a$J+q3nRlR(br!U%$S0UpgyG!!EG>$udk{oW=7OM1$Z=~aWX|LB`6xt zNCu#TI6*Bsx*-uv#gW2cYWt?#?pKKiMTSY>^g z%A4>*rr-q}aP|!FrWhpkS9j?R(Y^JlHvd4^M?Q47tz5R!igP%vRlLGmADQ-IL>$}@ z1DD##H(mmgSm>Nw0aykyO`^MyTJ;4+li3A6bI)sj^bLJRKJwmYnt^MEvn5qW%I%z;hh5TuGjw=T@T*y84fg;f9 zxqb5or!2Q%k^S7y{kkn#y2|OArGXnWAQaYq^q77APk!G{9C+GFiY}fLZZO zpACn=ZV3CI_TyrC+dDsU#cy3fnA*=xJ@?6RJIPNb99Cx@V-s@%O#7=SlB za6%>v)X5TV1gq<8m;LBRPurT+D{WCri<}6)a>p0qCTv6sV|;W>K_d)4WHTxcRf?gZ zsL2*CY?1L==ikR*`QxF-zHbjd`Jl}dN^wgULA*o}s3S!@rgC`q9-Y9}ir0g1K4t@L z^-z#)>(;HY`Sa%aYp*+B*d>0*acAcCZFg9coC~J1d~>PVsNS)mv2nZq!5`Rj&%bE5 z-1#vX$Av;oOz4U}#|1Q)oP20LKS%`qB7sC;G0y&$j6BwFeB>!pkrE)Z?dq?&odE zvZX#+&GHSC&O$0Lx%V&Nt)JQm6&L-bfXtAX$){7UGT4_b4 zf6#|n1L%I?Lbre01YzSCb50md+Lq7_1I2x?eOv>)F9iSKFtFEvbUEq!%e@8|WdpB; zL!@z~mx>RF=jpP5%cZ>>Tr6MdU#qOwf;ZQ< zQO@z@+u#kZZBMpZU+@&6-7&*(Ne}n{gh2a$3<=WhPT$Xl z%h8g!*URJJ?MQB!-u}zK{aY)ksJ82Gy3LBpt8FT$Pys`&q$&zWmgTn?G_VX3b+{uT zdU24{i_gE{r}P5Qi9IXZwrzLa!fY$N06iJt0j^(EI`Q-ud2oH$Gr*~n?3nCqJ7bSN z^nKeTefj?Py~j$6HK>G)ypA|EWIgBG?1vBEZ~J!t$kr??wGFF_tZ~ke6=a{cJXuFR zaqX`<9wTI}glDf;d5ZX-y6D{57eT#)Vc^S|O)I%p@EJ zhX?GQZ++QzAKqarrHo3d<+-XH2BGXwg?M~?1OnoXvo|B)wHyY3kwaS0FLtKzFDXrq z(^kW+^+db9^z18^GnQlTz4?81>$Y2LPGzl+HvBX<;V`OU;zN%Wst&yl3eVmC*UG6-|29}Q70Uhm*^p8 z($I$*oTv{toN{Jz+|HahYEM1>m~Fdmlihaf%~n=Yq(O%A${Co{K!sVA^PR2s_#@x4 zlSiJh#S61+*aXO_{Lu6IzIk2HX%NH#lCUh zpIKdPpWV8(%Id3?!kJy<3s<wTO{~BcB5&UE=xkk5A;5S#?ddEn2w9&yD8`Z!1@<)F^kk zEp1usAEs$)Xtwzc3&bc5R#9AK7dpFb{p$7dLO0r?<`#cD8SZn)L9 zZ@$4+Em>pp=FYSH!dxpTVoezDE6YD6yz9U&8=f4pg-hqjqpY&RvI2SK#a6`EK^1r} zy`-$zipz^EuPoo6dz#Wh4~3~XLP2SM60cBD9&t(Fbyn85*=UE;!@(<@lRr2xY)20s z5ew$1jSZH3JS!n79y?zcdW;o_`4IFW zIeCW;?z86kjkaX*0^eJJLX}T0nNVwF#3x41Nzr`YUVQF*wzzIoie|pm)W{*s=~mhy z%Y#Fi4))hFXkQDHm;c{g~m<^Xp^G3s)fj74&;>y8s)2I!D#F;V&4(=D*Oj>(Ni%b8|YRCOqySK|QBWo`xJzNpaOAPJ%$-blC-ARm*KyZ7#uGd^Z@_4QU( zS#2|t4F)bw0%;!h2Kibyx|)%$8h}2Tma!ig=ywMKr;HDVq3dxZ=>PJ{3JnHZeL%UiGpe|8J9KY<$E~Wa z!S^W%afm7&%h)+NUfo@%?7*RCthQ>{RxZx7s*--`O=ea#2+N=FYeBBAiYrve%V)#t zxZ)@Mcdnl2vRK-B%}?7APe9uKX%_@a+Q9-PnaZIcTIIj-^ia zM6pD3>cF$=yW~x`xwU3R?D@!=rHFV09Xxqf=Qr@c}Fxk4z-2b{T+YUxROnc9By#U7hFrb*s|iWCkMfn3P6L=?s)% z{z)+-&l4n=21yu-2Vt#AGXP?2|rkf84=#9;eOiV*qSvVL&gV>#%!Fx)Vi*dIeI=T zOleepF^J#8Iy!Ug(9s-w{F#3H!Bc&<^KhPx=E^y|{*!j+hkniO{^W1jjkkZo<~FZVpB1ZG z5sS@R%)t08P(qhvV8nB+N^s^BN0w*FhpiARSYz{YyZwD1x0aPR+3v$b_W1K1x=z`y73vRLMW?dC3`MvN~%c@vs zhtC$-GkXT@u@}4TR7bHbU;BQ${+170g&c?}h$ZKW1NSBv&(X8;m`*O)7tWX5-h#uQW!ILRjE=|0c#5>64a*i1U;SAJ(J@YUcf;b@st zD>(w2Sq`tm9f%A__O9aXjASN)k2k>(jCx^$gx30Bik&alahM24?~ch}A*quxD82o? z*3);vPl-Ex{IEu!`)%LBy|#1j4%@SDw;ecqz+T?_lI=UR&!4l8V&9>?*4=;J+RnAv zk&}n)z_A0i_t0)VKV+?ETkULTn{{@d)2LR0GC5|WA{b{MaMUVX$ui2`gS+j*zW)l@Met`dkidnw48*-p4Jm(a5xbO`jzb7k51zID ziVNiMh$9c~JEUaUwtVR_U(@%&w3xJ~x8DvPIB10xRo2kZD0|5!IO@go)u>x#F$M#? zM$r>t?CH7SqlLqV4|`u+f5Qz{rjZ!=cq0S{jJHcT>w)k4v-hOjH$$;hg0LrV5P&ZS zhvjjW0wV>L5AJ79F(;Ke`STkkX3n$DYj!Ld+f2Nj@#j56E-;7VDlE;YS(T5m~Gj9w=G+- z%IX{ES)QCzcF_t8IcPE&dFl)LOpaBygw1DV)J9CovQDF?GX+JGTbD7^C4HCx`595G zK>y8%%zvv`ct@=2Y2X*+3?)nro`C;-0XyjnoQJ+s7vyA5+45z}t-iKV@hWeMJd)ar z)l!%8p*J#)j5{Q>B`NkN3PDmFZoZPq>zJjba_(#Ah#t8K1=JXy9;YcT>&!$XZ?NGE zy!6sbewft4g$w)}L3~#ONF1|dAjs?kI*h$vr%#{pflY07l_LPXIFCXiM+I^=RIeNu zPP=4Ed~`(pyW?dml|ypf#`S)_4QTTPUSyn;cJr8-m_*i9m-J$7ZN2pm4%y)&ZL$?- zZEUL2Zo2hj8f<*TmaV?dnwPG%rOVb@i-&dAvYacYC$F=`%XQWLvQ?XG!J?J6Y~@C4 zZdqkTrPX%u=o#6fQ{sM*7ogr z-pY$dZ0Y<$E6L}GXm~mD~9a~)?A+M^m z9w}`0iQvUEU9VD=CK)XxX_L)Plk(KNyU)8b$7lfuVw;4$L=FU_1L~!}S+3!uI;&T& zwZ5KSd;008J^hG;(Wm=Wu)j8^^1unKVGbXSNeNx(x?m+mCGIHloH&?pa%|KN%E5c| z(eDfA4qLH&ku}uU%HJKc5z%8ZtI$Tqibd5L@j$IjTA7W{6v@fUv(sk>M4=;`j#y-1-3va!#Ni0A{j?5mMD5WWXu-#*`*eBE_hWz=d#gFL08A2cx2 zz>q0^bj;$#i{)6}tWsv|rB`0|@0v_$&;l)yLuOfn*OQsmDdS^d=j3=W0BUM#u&U}x zr!~0v*(fp^e9MTpaQth#qBGx!X_&jv-gEoMZ0*_`ZDbVvDH~BztwB?bm6p|5d3m** zC1$9T;P(opW%Y7)xXzKGuC~g`Mk_9^v67NHrK?vtg=$}gt&suWcGLT;cFugCnPr)P zWn&IMWLXPGi!*ebIW(~G*I~2rrRz%+c&m#Ip2Amrnx%Muv(AIkp`gS^V#Y*=`V@zB z*EBQq*d%6C*#jXOz;{Xp*?fST0s0hv1cIi-rGcR)Z;tVX2!l4t=Ik10ed%Q(r2sy& z@boz0qdo&w_=NtT9v?8$cG(CAoO!`j9ea&u*m1Vq*WcMx!^ng~qBuNfT+E3P^R@~9 z&U2k|9)4uczVM9CA`Fc5TYg2J&1-40#j6%t%gTkeaQOmTxMG2|tXO1Ci|5(=h4ZYj zxyg#lO02!}jIGV#qNB2^xJ?<^u5nOW$S0OCNLIC0Weu3RNy-{OXzGY?2SLH#&Q>IX&(^>gO< zT60@#tF^Vai|Mniwr-9X7sfIl=`n-kgFTjjW+tq^?}F{#wZobk>TTotwN|12V7gI+ zwSm4Kd*(;#*Qb9d$H=UxINLY&^z`@G`EHGX`g5$OFWUx&^W@QH+2BZ)jZc?|m&KMSWQEXt>rTMl`%K*WhNNG`)Gnwfw zD=pU`Y=PBO&$W4tEw*Iwa<2<@!MQ7($jCsJKF0a*uj&${pR;CEjtnWbinqA<1OV)U zv$AIOI=khj+tl89vR`Z-Ptx+rZ9!g<+NyNoxm-D?J~JTw*mJ%mDtha z{gT0ATfA(m&5`rQva#z`vg$vorN}D=Dw|U`*Jm5LySv?c1n^e)8X3X}a(t8j$)}&N zr=NXF4qKM3-?Gkbdhd<4_2w8${q-2K-eGg zzW2S)87~=a?D5$m>K`BB5Ej*!qu;r_rZp-j;=G)dbJjRlP7UYg^JWZ+L;2aLS*Ey^ zYc^SV<$UWnmt_Z!j@UCVOxc5v_1HJ=KWX=V_lSM{+k5Ta`w!W-A3S9bKH6#zKX%#< z9vijpz8O1myv+|*@%N$S0l@IN*+pu1p4!VsI=p`zHk<}269tZ-W%&Z-!sy5a3po7EpBsg3??VWRS$hj zpnW(I@Ca8lC~x%T`Wsv^gIr!#X&-psUH1Mv?zFn+Nj0V9|sxwPGpgL_ru7r$w zA>UHoBSY)>coOFgTkw|`jrsHvp8Tcn6TiQEhmnp zVH)PIgQ@_|;b6a+r@dIG9F!}aiJ%ae24>m4Ymb!`m)e2_3uO=!h0X}r&t?}<&R;lh z-~QgW{B*dDTh`m0d2=kkgq^NxrWz^nl#?LBDSa~CC}L8FuB_t>3=G(@qsRR9&ic9a ze(Il0wen`BDHYHtOlRVCbD~&PX{C>phWdx?nP;D~;sT9+g;TeT6(>;k_YPS1xeM0a z*<(El7YJv&t+%7sx;wg+PFJ4K3g^4d>)P#K3S^4^ysiY|2=RM(aM+4+i>)ND#728Z zZU63rc4*gOThX%GKKR}b*}}#}R#L#!7YvgqKj#@{F(lM4?>XSKWDSy86ga_00#XP{ z=1WMtul>p^J8bddCAMwb4OTlxv=QSb2iPPG$KC)H=mO~PW;=rBTgY!?tV33pO@1Xj?aLkb}d12$kdgFGVce=QcFj>b2`^&AQFj zvSf|TZ(c2jV!2gTEf?ts_Z;JWv5z)?GzuPIz3=rz0+1!)?mw5ZdBhk z3q6e%gfk8WvqOo{Cu0hi1vQ8Ed*DC6&j_5_oY0Vv&=i=h^k@HmHvza`4gSdOmFBE2Qa)QR zhl5ciBdr{FM8zMIY_gQ$nobU`;>*81lIfGjPFly|E?d&F*jBGxZB^w=vrDEF(0Ry5 zIHZnbDqJaTuRRElprrz{Ed`SAzTRFt)7oa0>brXMg=E-mnDi_&1ILdY^K(|1QJ|06 z*v`xY2YLYndRluX9-;=+yGN>5?q`!Bv&nJlS)w0eo;`Sq~YNqJG}1&*||1bv>?|i%hW#QBexevx(r@Pc3BHA4p~CyvRfs~#wUyH z*oi?K94)m)OSjmZ`h^$!9QmY5!c{G2;4v~v;26%%jt-yts;WYq;R$V8r zSWW~R70PcYVxOELv%Wev~l;QkxSVahfJdZ^k;#_gLGRRw?XC7fJ*K;GBBa zE7|PC2emld3(voyX?{j$h+%OhMVn-rHeo`T&MBPmz37(ZBMg)_A37S)NUu^%TTqZI zfz${<_3;(ObDgbnfL^hAbL;H->o!?Md8wC#5>3ixw2&vIQ(@&*^;TOq-|FfY*^*_P z=^*8>_Ig{l;YM4#VY{tbf4!WIt+ssK^|pA~COI5zZdzx{xL>?lZRI03C zT+&wWFDeia$Fp*k%b<pBY6$L9EKvfav9R{un7*YaW)a<;R z;z&OJ$P5bpJP9*doNlRG#dm4&cyQki4P3`;$^3lj`~f8>@PuBSzW^s`^0)4U67K_P%J7V>l7{R#V1A zgzEF=l36zwJv1&{DKS~{NgN$Hl~aXsB6!8K#9EeK=X*qw&FZAi^uHGuy?-SjV>hXh zF;n8-`qsDXj@xeY*L^U0lbHrhOi$SHGso?J{mCC#`}tO@uCKP@iXwl_kuw>1mz!6G zn6_lCELoRR8;f8}p{mAqwV$&GAAHERY`)HxE?=s2$%$w_^)or)2h)s?j@!iOm<>w7 z49S5QywES>K4#}zd+gNVR{NNY_oqJoNuM$s8Ryj%d4u#GbvctHWI#;lW)2m3JxG%+ zrkUL75n*|S^5G{RNoEn`-JUvr$`;LEXdk-sqjF{zT2Wq!zdl9n?$Oa?mFEnij^vcG zmk%Dbd+xc{KdZZO^A?|F;F#4R3IDELdwjZ)Bdl4koG<6dr_Dq!oDE;2%%ddzb_Pkn zc_&kkw^M0(g=?o2#|tk!=S~I6gK12b38tq;?WrezV67()*xes^zirvL(MpO-ZH8An zc}^cwo-;B`mDGcLTGoi3>7q!2k1UZy=q@_3k;pd?DI{y2q|@W9eN9nSUkzYf+kHW}jq`)|ZBuUC|%9(aYnPw-=2(Y3JmiJ@}PBl^uM+-g8T- zwKVaHJsvf6(?04{26rUDPq*ZsVW!R-Xj6GpHZ)dk5B~6swVkiC_kQSK*ow6`%8@Qi z>T-M%+^^ylPcKKbnfB@S6~{ZRf13LnC>O!q2sKYIM+Ur;eVolZQ{)usS7ZhdXN zHPp|whPt`_4ga~d3Uv+EP&?Neb)74NO&kYZy>?KZng*p$uIb-Qj~+UFRPE@tPPJiK z%QE}$op;;PMT;#js}Q%-mjZyK^OMd1*d)k^r?{ll*MV7|ER|Q!j08uFKm9c4KcV-Fim^n*l~?E z&%0w+lwawub?$lPC0o8^vE97wdMhg~_PU&R$jP)K%*p0lJYwqd;22XM<?{eg$S5db|2;=)#bVDed%#%+XAq73Y?^+^$}}#;)Id zgWa%YyKP*%NjjiGG9;dse)A1@y7!N&$w9%e23{UAXeW~O&H?ZHjNwmyOD-knrE(6= zwzvBXWT6Id%*;Ob{Bt%sI$>M3Y_(M@S89M)=K0-FF+f57)IZK=3?Ot-j2bOEC@9IR zU~+ui%FD{Fs!9V&-5)=C*oFstd2`VA@7XEca@;npUuWC4UT4+fC&rFA4^BnZDUIS~ z`qgVn<{^C<3Y;8tmCm>Xq~n=V!cR))zYz`c_-{&yza+iS*e0bbF!aiiy=U2&vUtvw zLWBv$&}5!)BgdfoT&Ep8@Um5w4%w3C94pQrQX&+Y^0?7ffNq|PIH#Phs*ia6o)#Yu z`%J_I@p_{zUB4|^&H$f`TqGCugwiLaITp!X<{5P{^K)$P3pP2{YKs@E|n>CPKAb!G;CKsAMNq<#N1k8!o50UY7nPO*c zy>8AN7dE~INrO2PV)y=C*4W%&%h#^3`g!#>Z^1mPt*ds1=Hn-zGbSeHv*LsOL)L#` z!1}uTj zE8oA~*4EZ;d>n?|(g^Ps4ib4R#E=ZFGeD)qQQ)c z^W(55rD*WHg$Uo=PZ$-)Ay4%sr(MF-oLjG+*Rx6eMA($a=_?`hQHPjT>5|u|pc{ff zEwoL*JwF4uPyc?4AOt&%_v=C0IKGvT#^s{I&%m|7;aWQpf!}Nz@*5+~(t&>}T=L>0 zV98r?VX>5Ag$4u7wtVq2TeWnRtx#CIa-FSRwceJtEVl)X3;opFrHhtIi7wMUVX3WJ zywcVw&H6PPZKJ}LO_0{^0C~Q5m*F`}f(=BL}R%ugA7;-Du?=Xzm{@n+xxv^QJjn%#Z)V~QYxo zcKGlid+vo7{R3v3H*dBZGRMv$YzV&}U9J2R^ofL*I+(reHRCykv*>zarY_+a0&wXscH)m+~%5@I|q>gD7UoC>UzOqvp`B($90!P^%8bvr6_nk0?{!xeh^=+(m8F%xkyPIBnqy3kLx%s9RtCl-F^ zuphXESM!Primkk)+-fRoe4}8~+8c&f|4EdU`oMJQLK;dXU6T^`BvM1@L8)U z9TATe%83~Da+KB6vVYI(@>vxAW$Mx&``0LLT71_(QegWI^jU7coQSp8`%FaWy1*gn zS6v|z?NxAkQ3#q{{WLT*Y{&MzVB=$L*0M-WL@6nuhtiUeJs?gGU7T*oO9_q^#VGBR z`oUlE^qGjniKwZQ6QMpzj3;dps4OtND)9Ap2^t0-1IH9RALSYz9`%NL167fGv2rN* z%+ES~ktF$@Iwbrz_!B%Zr<*#lQRutIW1@*RI zS+mtQ*I8wKxs}$GSV3ih<(1}IPO%77IORuBk8xZlb;3w&ymT*&GY~juTpscyUusk1 z-3Y21JU}0j#rZ~^rhEja&}#)5LFWrPMfv=rGrBLUEVG+#y2(EM=}-GtL^*Ve4X7|S z%NW$ls2>FG!3p4Vwq+9NHLF+ItrEJTLXHNPvaGK3bw!dvduJw<%aPTbs}4jN z0MBtzJe@}*&)E3t&r}9VhCE!WD$0B#TS4Bi-FU+q+puoAMiWx7AS^Ow^O~0KCo^lv z7=1Q_^2Jp@GegQZc`w|PdAV$BOJ3JxhC!99ThtsUHFBt?3iO=(%vLClyw0Vd>mezS z?3W7(B+M<9=fBxc_;KjvPnKEFRp1IE_jW=@2Ha_t@)DbNewWoD=X}y zcYnlgzx8ITtEu+B^U|ox2OH$e)B&04f374#{&nvLJac7WH0`pkdQ(~;0WuOOPgmhW z8JIj$`jxUAhxnty1TY;Q7|f7?9~odA$XOA@_^|_76AIFYszUCjVhV&CyAzUtqrj)X z6IX)SSVE5CrGs_PbxQTbJy#&;NqxG!DlQ;yNt2d+SsKM+pf1(rfjact@$@+1EE+JJ z1V4T&gCIk{#K0x>LN7tGG_AU_us_UlTp_?9lQJZ1hNJ>Uz-!j5vBt(m->BDm>a>lq z+>inKkaqc|6sE3oIsakY17|>R?!%vT|aeQjrH&KBrLIk8cL-e~y zqzw0X;B}nK1^-rt(oG59X*op^_+40yN zmU6yV*tl-JedaTt_K_dwUGQq>xpU`K778^Hc6L+)vY%1TP@6CZ!SUB7jWMxBKca7ZHM;GL*4#H7%W*P|Rqk5oXaT(XH&JNquPnx)rQUV7QfL_fXveRtS<-+PDE*VPI+_LZotY0fT4 zHh`)Wm8t$vQGSBG$`Q5-7tOIUC@vEcvqu{G7F@`z2qM7;y!E}9jK23hscl^ z@WjDL0^nu)h$f>`WV^r#6_?D20*cY|#G#=<9MLa_R=M4H>w4*4bx66f|H1EZe2M3` z8*~i2?5Y|+TXwlk{e0E)xKD?~dC2wh+;Q+aaZsp7xN`=E27EKRvP8jBF}QiV2sjVW zBFam_tNU7|Ug>fM1;dmu6Aa0fDM!}Ycuk0PnfZ$vEvG2gdWZY$m3^;RVR@m|HP$)< zz$siXmjGUApbFdYO?Wok;TkbDKQTUGE0(SBb0YY<8%CU2hGeH~5_}3&MB+*roO(9g zJ7kA;AGCRO^KH|bjaE^{W)%_5Iauuo1Eo5ADI-D73HADa$`8V1&HRohltI_AH-M>8 zUaLw}m;&W0_)iCJk`CiO=`h-_mLN?kiw~4FsNI|td0x)L>60g{??Sg9s!~*t@0+EV zcI87?OtDcL#U@S$tepgQO!KwPd=$y(u$AWL*l>Th^>nt|>0<|N_e;-O*5s(Y=eF&( zZSxIQURv!-Fs{TQm#)6#;^gv{Ce4T&Z-#D2oB6x=5YK?%4++FYh-8<3`{?1)gUny1 zid49i0!XKLV?aBgc?d@ON`SXfas-yCP^>tkF-i*oo0L-)PAbp162eJ~v{60~c_S{K zBTMOY@q2duC^zm2-~cYfQ#bf>ANhfF8E*#Ou2(!0xYWDxe{xNN|9_FExcA755b2T- zx!*YWtfau3@~WW!xW>;};hhBZgILGg5_|%dA;aigj>0Z4Ew%;o8sy)P+1XP^?A)27 z*4x)-lOz3BC}%!bPDk$agwk>pwfKqeSuoz#;BR_-$nvJgHD(x-P8qPV!Cvb)bHYv@ z++hcHKX3hA?Y6kN(QdhMn{8OVN<32`4CptExI(rZKLytt{L2tB-TR4d4gH!tO9HWy zLt*7Qou`@nnaib{ixHO~k%xpHvy;iUx%5N8bU!n$UZ-f9Bd=k4LS|a_qpz#Y_U(L5 z@|I;4)$^@?vciT(tF3RO)CNY26bfx%B;N+PA1k)LVf`L2w4O1ufvG$@H&|eUV~y6? zRc(8BowtJQVyj!c&iI5n=WGlP4cPFY!qA`}Lg=qJ`&)>EHX=PfGCbsI#$+RV`v>iO zcdwn?`;_(RzN~7VWfjy~|7f=AmcAb^@)IZrhRcOhscfc_rPecACcMh* z{CIR!a;E=$m>pi{#c;RY4XK=Ttj&WeFD*{Rrl4Vv?SMNrH)8l+ZhU1c{0{|C(<9Kk;3x?@jN4s^M>#(*{ zr{tV;T1(49yXB_s{t913aq_8I@{yLj++!ernt^uGUT7b{VP}l0Sf*oomgOGKL|M6V zrGMCrw9F2%A86gWb^a_2IM+)G{PGZWrk!%{dtHM+NRz&g{)l+ecXoFAEFk^HH&j?+ z7t1VPO`-KnvIT)yTLjoUBGPT!BZX1Q+zvO z&YXIG{ku?n4==H&jAfC@$z*mHXM|V9Iqtr<`+_fNkB#&>pR8NG#+EHxYL#+43sslD zo+z~h9zH}^5lf+`PhU8;5oH6I$UNu zUuhGKt8BsMTdi6Ha%cg~{X|hhrvK8i&w!ag9DO#ZzG&O|gbj_HvzEmbR#mBf&KXsE zIE|QD7&f;{*LZWu%jR|fy3<$<+Ewn{7bfU`E|lQ&&Jg@{65R3 z?=`>?B9|=9n*_4Ps3$s~MsfJmeP6lHCZ@*i{U3S1S@Deh-Pb;Ea~I0st!c5`BBf`# znmq>rH~@G94k89lhP=MqnS410YLGfCMtB^DjR!QvGKj8#PhBD~UXE^+pEo7C&s?x? zf9cz{WrduGpZjHNZdl|D03=QXcW_2h|C@0l0PqPCrmu;hCY>D+@w4v(h>JizrU3a? zJl-%z{c@;^`pTfct2tzEs+KmG-s^AYwGt^mIy{ImjmOrXus0eU_1$Rqaf z!wcM8yxr1MYiD;<4KS~kve9n}b#=cUC(?r^Xfn^){> zYiq2ugbn55Q`CshjwXPvB@ajRa|PgI%73dNynNA?eya(MWCN+Z`BK4SA=g$5ftVkk|VapDdNBJQ?BX>`{Ns zeQGL4j$DBa_2cxH+hkFN&oH~*P$}TV2g~>!xYL&aXmRi83D~cZJ=CLg!&Y7<8Xf2JWPoG;QW){{oUX7Ppsc@#~p4!QL+y`^pHRQ z&ENb@ANBwBU;niqy7tSz{L4N=0pQSaq9}*~315EY8GVa$$k`#xfY9$Kd+?y_fBw(^xzCDl4_r=RzFGK3C*qwA z!LtBMnPRy_S4i$djw22H60>FgEH`l=2=Lap#D84RdV72A?AZ?I+rGg8=TY)fCS{Yi zxS-IF$(}p6-ua!6{4s;dEJwl#;t_PK{zJ!-AK*SuIPe;8@K7}2?P8&j_1TUKb|+aW zOZH=6WI>HV-1L;_C5Hf8;Yk!XHacYEqXT+2;>*xe8vJIf$SgTom}ydG`FDVleHiRZ zvD|U#@nOr#W0qM#jtPdF1Nhj(5PFyLp*OD?2kn51G!v~zhpnJ3^xxF@n9_^x>@n0} zz93(5oCH0?GE#x?RH4f5FFmNS315ICeJVc5?h_v33EAcXIW+kN8Wbt7pD?X_I9FM6 zuCTgS>_m_a#OZ`!&ba3~dSS$l96xF$)g?AKF<@W3?~7JDzuFeBUTlSBdC7*#tjtn~ zkYSfU_iC13C$ffoDT5u?St4qF4pT5QPHcRR8F@i?P|1`G-lM!@pM@aM*=(E1v#!(K z_N^~{*RES}o&CzEK5O$E7WfzWAcU$?2h481>Q63j3SNthNt@mnP)3x)>1_cX#~LEE z(r-LjoQZ@BOoBabT^G82&AR)1j~kYeu~9cXFkPGmO5ok&x|&*_hOeor(1^LjII|vL zWrvbur}XSL@tgQJ)24SD(8f?K%mlH~^uPYs|LV{F-M{;H{y`#+9cN~Ok^h}{-szt~ z{;R+GD__QAb20fb>@6)V_OXwB%->q#H>bLCR5|(iDk7)5GHZe3@Wn5F(U(hb5KtH# zWzK7NoDK5C6HmAj=NTu8QYX#?Gk)w*;6q0MeYMc zmIXa<9(YC_(4T(d8U4pqxtxAnN#~BE{&3v*jVa-+Hi0&>HKSY{P0cR#uXlDoEmWrNP_s>Zr>?y4^tBb4`ag1L-_mypD{}i6YK$#50ou zGx&6w$da3vqjr_roZ32Tp4Vi{mM*rXi(72vvZc0m^(qgmSFW(-OP5&7!UZ<3sllo% z%l*|&&SwzEt40a5%8f%`0+)0MNZA=u48f4=mjta6!h(5cTzI%H&R{kTrS%6;& zz!Yz$>E4$w#W&2J(Jwfa#Pg9R_Arc$Br^+)Fx`mhnd@ zF;^EC^MzFznW4dc|IQ9)LSSV}H z2R<^%?0a$sgYt_`P=;HUN}Oi8V&kBf(F8=k<41oI^ zfy4ilSL!G*}+<5u>{_K=NDLM zaiOl9*N~OWK0tQmVfQvPp`PURFVPo*2QC@l{NpW6PMomYZoAEX{nvlp<8dN*wqe5tcQlxB*s^7dzgCB0$BAIJV!?t1 zzMlfeUJw4!iFjuN{Tje}K1&ZQvHjo&Kk(Nq=@*<$Hbx#jddwbs>~VYQ zsi)jQu2DSw8oa`|mTq)StFpLD1~jN9ITIRAqj%uXhaP-T@^Q-M)YbaTCh-_mM%PFX zUmixcFf;n}(@)zA&p)gD1y-x{7|M_{2P)v=|G%{Wy~1${Kra1L3?1jJJ2ShByp50J z5XoucGvLt0aV9^`8uXD(%Qy*40mxLVZO{xu4L#VGHl`dCP8VLo?>gV@rvqb%3kr&y z?*7>WDiBTsef|C_KQklDCdU8|dZlF=nsM)epL&MDcY8REJ;W}PEWu|gYg{-A@9YWT z#Y~8B$DCq6u*J$dHi`3>WrK1yJI{64vPOI}Fk^yBzqJIK5Fa0x|?1mtuH~!$qSH%V;FPNFh(2F;~Kj9E~gq zbiPIzye`Ye8fG(`M!NbMC2XfMDGQu9EfPpGDRrg=Eq(vf2+$(v%M!xJKmKvwF!{4T z`?J18z)0$`#~!mk`?Eju*#+L%fL17B_DAq`295zU3Z%s$Vfg{0i=%qsk_yfV_28@or1|Wpz2E-d z2DAH_vJYaDO}4lsais$T>iJq09gIKmW)6|G)qLwLkd7|6AAp+y3AW z{=mNam3wUWuDu#i3=2>8>S^F6o$3QO8D@g3Y4+(UFe3vEW@PhNmLKdJ^iRyQ_i13D z*G7g1?FSD&U|;&u-`kOchirIY&{|KNv7@^0I@{spzxd*d_O-8l%?BtrH_^Y~>@?2k z4cY-j-}n^7gq#uCRX6c!9)3xV+ebM=*;D#0n=D5L`;dIXK`DJUUv((tau$<~+JS>R z?Azb`nmzHvqjIKtWM6nwgv#ioq~7HJD}?BLAR5Az_~;n!iwjHqy!P&{3)X+3&p)AU z6Y7BRY|EX@^H22JgnD2?ovM32jAL>XFmgG$B2o_9C|NfWk$7!}LyR(=#XUPd ziP;$uCPzjxOJ(z+ozAv%>dhIetFH6SnF*mCHPw;1T#S(Ls3u{b`F-^ebhy~Tf;8PO z2RsjYCIC$n0ZT&&9c70=L|$IIijV+Kf}Y_vsCxfeYG$eos!}}VQI@a!2?Ebx-YW&l zd95}FeP*Xg-y4vjZH$#Zm76SkNJODEjWzJoV0jz&9Y>1|Zs&hS_O=*}vaPc)hyfS(>*4(!6}N z5d9iJJyBO02R7Hjm&`cegkrqg+Rlm}lGinP^NXPi5C{KQY zJYnB}DrBdH?vxB0n=2<}$e3No%q-9eJfG3CsZn`N`aM0SdgSqScXeCGi7s{AF}u*& zV~ggu*e%;{v3ZU2ljh5SUR=W9a*=6;(8cI$qj1x7N(&90L3~^zF48bZ;MCM3QP%&M zI!X{s=nRk-dQ*Nd6lFxcQFef@(TxPdB zu7ou3n|wgvnWhgAdD3|!-|X_m}ZRS2xbDfXG1HS zDA{nx=ir&Gz)|4&4}bVWR|YIgFx$Xv0{a(MuU>7-moN8G2MYeW>#p-pP}5dE_s$Xp zGa&DI&wG5+CNm?H&*nyyGtpPlAf1LZ@y@$m_J=9y>x?TWIpa`9rJ ztzW-EJXPYeTP)KN*gJFc%{TjU8Kd4WfBDPa&rd!5j2%06-1kk?)yO0fS37N zC`*IL3A1)SO`qxCG`zJGdNl-};2<$5V59QVYna<0N8+HJI(f=* zuB@z({2sSwo_)r4NJiV++U11FS<_&dvwQaKJz$3q>{mP6CF94H?~FbD^z(8Oejq30 zwB)DCYN~5w%&B+Uni}THS!lO=@BNy8A{}F2U0Z)~MiG6$3?uslp&fbw9MK`n?r_K> z0~quN&(I5}jvuw%(gz2G_dtK2>Z`E)!W`@G>$Sc6_sF?>MSZhNxSv}c(<9vS%Vjtla}dad_dpY@&Zx4!N{8}1!R zu07)(2D?XXuxHdp6b8G8t*>j)`p*wpZ-*R)wqCo?+GianyR7Yahjr?D;cUMRoFBBZ z!U|ipY?a-1^X;}`=?b3_$>THG><6F;8GCeTPlN0gD}Dpe zFrz;qp$A7egSPttewU<8_eavfiYJb`6{t4}c#p~bMLbf?1P@*V;sZS}YyfG~M(B+~ z)I*52#yv2*T<)WMu8}|D;(6rZS^5e@zN>{O2O&kMc{r03`%&+D zj-cTr;85_k2EvVFz%vv8vk5pDOq(*wAU%!=Yt|@q-o9A6bgAd#H~E-}pgi_NfD29x zn>ukCadJq5@&gAxg2$#z_C&DM5i=sQ>3$_Ndb^znfHsDFgpP4ORe%qoE!+o>@r)}W zfHYTv*BgN-n}8wmK~xI2z$4PUJ&-IVum=G?<6IMX4_*TYW(eRh26q_u?c2BeM-F*Q z2;}E!95gmH+Jc1(tn*x_9XZNOU5jnne4RTU z4?OUoRg{-GAJZ2&x1-}@)+9p+|5840Ii;P50H;odZf`Y29l+a~H@^8*+p~9% z9XWE$j-NOw8tm5nQJ=Nsjg|TH=lN3Tv(G+lWun!Y(+5(oAlvEiX{IR}-pduES4@u+>}D_^jOfB2{!J$%#-A3ACs z?dNR8@|Cvbx^0sE@`SHh3a8tY=M6a#L1aSwhTwEU#34M~69-ddO)yHB_EA4`719A@ft-JGr^~n+F?dX%E(dXB`&VK9B z^+LOxhO@oa-LAN^eKvey%q9ndfT-AI$PMdz<9-l z*#(X+MKSqV?eI`01VgdOC5gPY5aovh7=y@{ z2J#U|6UHLa5#smNK;T0EU@*g2GS!LEW?Gp24$LaBk&>xmW&{8n21X6cI$+!x<*}I( zgU<9MaR@y#2~0DiXi1L($2s6^2b=(uEA`S&rjMzY{LEZ%z!g`PMc@}s8N^Uf2eS{9 z>7xdXEMq1jLco>K=&f=hE|)&^2|ycw;2D5Cp@V?gvv5E>;~L>wLGWPk8-cjs$w(8= z0n)!!kZv3IyfrX5ION6#zQxhSVSvZLky$YM5kAF8ZIjc90mbQnuYc{=e$8jU;4hyM zE7AZ=BVOJJq6{|k^7=BoeDuh1=QZ~7u*AVE-3`}Y?>tgbRVib-*PUPtA$tPA2PcAM zIY!&iWj6kACq#e2b1@T) z?cA|jve#hs^>gjnXP&hkyI!_B4V5_I zPMtb!qob2{)6KW~nKaOe&D_jXGULex*#{qd&>gs2rFYOl;EU`-dzLqtVd8aibO5tG zEJYnXcHD8J-@pCc@5+fgW!G)lW}7x{wb7Ar4Nmr1X?d{?4EEZ6-@M1#+RxbbZMWId zB`bWN%h|K%{6mr(Hmp}a^JP&6B=nKw@r}+zK;$4aKqrSl5D#$?nCN_v#d+W)F4?Y( za7|mD(v_E%*}S>)Yl$soJicWMmfG@#%Wc(?m9~1xD%-evlWpCw#kPtt8&_PY{dz%#5&s$7csXTeK$)OrO9d zB}o}5h3F5@pHf~C2Y8?ApJLo)K=I_}aGb5m%{UiXM6C2u(($QW*B3OK2f>0iFon(TJZbAd*1S-L>U zvS$J30Ar2Kkr`GT>-fXIZjRw$c8s*}DKiE* zr0^#O2>!$Iz^LNX7K$fN$f0c!FTdxG_h{rjSEJY=du7K?cLv}&X3KC;&S})lNSqlr zj3@mAALB&ea0j1;6A`%oBmrIn|7V_g#!en@^;3a&?%3tCjx6bH-MZD@FWNLT%(bfO z3U~Ia&f6)RRsUKiMY4Ewnb`{XA-bzy1c5eZ3r$Gs@R(AN=4)rCYZ7%mVn(PskcGoAldfKJyu0;$*-(oE%XaR3 z**0z7VE^=2e#5rkaHI8U-~vzJ0IpxZPEJI*2H+x=_Z565-gfG}0SKWDfZ%wkC>GXY z2#luq4U{m9R(Trp&k)mP84Me`Qfip48z^SaG;)Aif!eYd^O z?!N18``Cv+qU(q3?hk&*KJuXt+lO`i&|M$&K-``0d9U4mo0R$X8*Q7s-i>S5SxfT* zISy4;QdnTcIYpMsK8xvOHz}Klz<-?YMu=cDug9;Knt9CuL1BuVscJUQ1nuzDnd-uT z!ziGaoCkpdO7f<`XH)cm^5_fNhVmlt+h2_#TwJP2Ao`K~q{Vw>y8Oa<;n8*8E_8SK zk=VWwO6kI|M;!rgRNzUMRubG(7Cb_mX)Cxe$_9_Xi?r$b0DcosUU-cwaRhI(faip( z1qU1o$Ip2fx6h@L+4}Y1^}B zmuQ!*-xaQxc|{S!z$v!8xkWpY0SmK}oEMS2i4ZeF$ZV8PyVg%wsb}m=YpW=vfiuTf&u}ipgw3gMP^g!TvabPJp+h++3LTj6EwdcF+DZT96(onk zK~HD`4rv;}nVfj|H-Gat{+b|xYpMkLlV= zG!I7S9%g~D@bXcv@hk-WV!aS!77$N5=)oF4uMtr{eHZ=6v?U&HI4jggxLEFw0dN5# zZ8{GIfoacge)F5YJ_tm@C!Uk z+XK>kL_ih@kq00>X%KL(5l?!a1^vSia^+dt2vR2ZJSRS#FUoTn(mjFaaRtaf8z?Wz zf2RZOpxumK*)b1)qHEw+`W`0~{$%9))vtclN09IX^oe$++j_aUtATVm^aFUn)8K}} z2k4JT7k&2j0EP=1!UMdaQQ!s#U4hKu0Au7sUvOpC3thW+-)`SXjI;6Li_bf6wk%v^ zixxIpMR}>O-|u+kCELGmkL}v|ik<7|^fuPiRB43WYtig;aDKNC3h3U*4N){eFGP4O!pI0qf&f~#>LMn zmXkHH;RODFQxHN(!w{M;ed$Z~7k}{=?o6-=1R)~s|NQ;ueHZBP=#VpVwi@7{?bbb> z8V1sp1iM-H?3YovPln|g5oyfJmIw$@DQaDD4koErl$e3(xvDdA^5nhgI;l<}@Np30 z_+C287gD#*kGv%UP#@smoxdDh8{P6Mn5{TqG>j&6!g=`ix4-SvR1ZAxfWHNR0ms1d z+7ONp4iHS|_kzT!;2@K1-=y@FuYAQ{x1vnShS9i2Kk1)`Gz3nRxogKxSI#&zyn+ZG zI5U6pCx7C?f|C*kjPe35m7f^J*&!Vt_i@E(q}{xa&6*%fAg}~HVqyx`%8MF$i+Ri6^A-x>`un5#Wk)0VnRE3496=AIcaYAI<~L61YcPIz44zc>u1ILwc@+$OrJ7yzg|N&EUxv+|WL?@v^GZh>BK_M5)C*of@E!(U zD9i8+T17=NGXe07Wd;8T-`cgV*Z=0P{?b18$A4l^J^3SRYG||@wqNhFTr7JazxUj8 zk2`eq!QhYvF=}Fc-5k4q%T_DXV2^nE>7IM;^*26{@k!x9J%L-AmOlx&oE(Jgl$GRL zMOmR}RBrWiYvd#pDIS>{w{u-xo-RMH(Ct^pxlTWnDKV}ZBu$JuO?{xjkpVN3U+04< zUTCAiby zd?j?6Be%1~16h-@fo98=EVLyp&2k8v)t_7J#+$CUm8+K8!Uat>ud&IOW7$)6;J_h! z`q`)K`4^wDmtTI-I?uJM4E2?aX$)!sakpQ8?4F3(oebe2AWVQq%Z^gkNO_0;{qKLj zGa*xsPd)Xt6%`iQisdVGEta=>&JOH5V22LMXvlNrGqh|r>FDgR@817iA4!mp*J&y% zs;s}a&yHx6d{BALcb>QLp)o7TFSg?RB7g1E{U3RAQm2j&cm-29lhS!`HpysiCQZ^F z@}TrdpX70fpQ)&80f865J&F;B3nsby?z`zK@15U4N8#1p1IMvE-buto>)oK1uW4<7W*KPY?ZM6fWx zY!YH7i+u%*ig1KT^MMb1z^Cgl22o!)G6)C(CxUfa_82hL&(wXI2h#Mu5(pZF5(cQ3 zcmj1r9DPWA#77#ZtppkRL>Z*v9=f0uNJH8*zC2II#}%OL^fk(*4$=|g8FkO5M}(^Z z`u(lm6A^i7A9O-l;$TuIXLGRGkG8Sd4_=1{*vN+-LIEsA^6=K0MwSN9X+6Vf~9Y(Vn7 zM*95jkKS#oRvu(Y#0ak0O&lMR+o!=vykosWEi>R3LkLNY*o!1yXofJ zrB~-F9eO93F~osFuO2*jz>Xa|DxPU~d)C|2Ypc}n3!0nl@S(%vul{6@)8b`v6!Pps zPoM4CyWb8UIV$5mCC8-R4*3A8%eHO1(H1RQYNt-N`s<;+J^jwh%(S6bS*FB3Kr46@ zXBGYjPk4}7AC@$knIIm%+qU%vtEsB9T|4*K!2^eFx%z3d`j};oBgc-}?mhdIe}^5| ze@Hrf)K;xpExujl_Jw^I`-Cfb&v$kEaSSV0thVbmZ?*EW3bjXiMk*5WL6aZrM5H^5 zdqOBEgon`}>$~jM<%2?O`eI59;irKRfX?~e_rB*-(7b+vp~Ry?39)$ukCn2BBQG1q zFe;3)Q4Z`dh9NPM7_>`F=l@^CkxPW+_disgmy3HlfqGGT&;fL!k2W!VOL~kabbxLsEa(P9!E6Wt(~)eB;x#RnOPJw+Au!UEP2FL{LWp2G>cm-K z&5w$K6?}syt_47wFwy|+M?f(u z;7-3$A5IhPXT~ATPdw*0d`~*c!+E02pnE7no+Bre#j`L_q{kTm_)Q=!Wr7P3ZKfRh zHSWn9_Yvav)xg`~L_mWG(3?>`ea$Es-e9zhzM;J+T6hp=7pIc+$QDZeEC>MZc$TRWi z7IY%IGvtgbeG2~r=%ZxA;d*ZuG^L&7hYqw88lw-O9f3Z9hR_%}MW4myYo3vo@+p@x zV!svXDVsRTfw%dM?ZC0-9=%RmxMwdGxY8c*q>rfw9-vL=9NLS+%fJn%7apLG$cLWc zH*}y~v=N*_KSe(f-tajQ=}Z9#4Z=#Lu~Qub0wA;~UziOe$8Q?Ru2c}jAmTA0kO&?E z3&CMJ(qU9ktTX`SfRF+ZlTHW)3px?>zsCVd+$Ef6ho2;%^f1P>4XJ0RBSILeQ@Tgb{;J7^ULSV9cO5{7roTIN%^*Pc7adM| z(!w*OCmqI`=kOV2vEdpXB%rr(0C`S7pmWi$*bN+20z3_U=@;@)CiEwcGI0RFDf)_g zWC#3_W9lG1eMf(T1N;jf;0ir(a-z&MJ)9#MmLU2sI;H zm=t0{KoEkcC}7g#?NTNJNTb+@Lqn+x;z1-j4n=~qLZibI1fn5<*KByL_@5+TP*Hj) zBH9D9fhTRoX}}4fBVja{2WCStkp|-o6T?K%8YK(ec;yF%!SK@IbUyt;AJGrwr_Y$> zpnmG6Jd`a)5Mzm=reAO%P^|P7{fZHXx$stDP?R&}z_9c!uRwt_IQ-%-{-P^x96$OC z28KyvdXu&yH1G{mb2yFQ$w&{U6XpdUlsq`|HUol#f@i4&0iZ0LO3H<&5DwaQEr33V zK$(ne|M-vp*hjHATsT2EFVK!PdiDdc2^Ag%mk>tUAG`;@<1B6pR!`@jEt zkEgGpN7PRr1Kd*vMvMIb7%T8VhA4;hK-@>&*Am`NCxZ6U4roRnz{e~p(C_pEyn|9j zY2zH=2*X#O{p@Fb1Prec;1P@&v}J@052Ih0O@sFEI9K?Ovi{jW`)59~gV)%9flM(2 zf;{5%!|Nz(J*o=Zb?BX$h1;S)}7PJVT%ie)A05Nx1-5>O)6RP7GQIq>ni2BSamP z2Lv9-FuIEPz>`4v{6AM`ta(A2!a~V`SlnaCVkAX_L6$2c285L> z3Xyc=198%VC?UKq6eR`?gT<(vJfs7n0Ra*JZUN|%#-C1Mii&pf48TCc0619CjVm-m z>A@T@5`7RI4c$nOGZ53SI1r>^HiD7Wpa1!v+wcGW@B79?lq~e8KAtnmLb=k9yet0E zkABp>XABknhw^3=j1xg$kPf`LQZEh$X~B&?hY2xy04xhGBJ$sKKU`AaZq`?C**>9qE2`h#~uEp4@rYl zz>)?_cJL!SfD?{RqOWkIaV&A%naM+mM}Pbzfi?vn1TTk-r1cK>1Zcr??$b1(4CE7t z=K%5Cr|XyvX?{#Uk34aWGJ(j$HSPn_>8=Gt{qP8N(Vv8f3jp`%)38_QxZqKKlOOze zp2nNJ{N_qpo^wwiJ$bp3CiDYoxF;T7jQaT<G=>GoxUm!9Wm!(J`YIMk*K*rmmSTBo3tz(=(KpZhUkI z$``>!SP@Y2P!{z?T>$xBjT4<0;hhcO9qomN{KhEJX4;7nX731!5`)3$9!15B0ME%A z^bTP3&b||j4h{$k7mt#(!9ZN869%MSn1nb+{r~D;{VV(L|NXz)fB7%}#Xo1w*#sCY zoDcS7kOw@N4Ty3AhX|C1VPjpI^=F=g2e{+(kOw0`+E@48CB*@=f*SzN+@q|Sae*HK zNEab~Uo8Yr1+RsI!N6dMFd`Tic$vP!a4~|VZqniqK(9ai!$0(|{}Gt2VAP5cqwS1l z+1dWT{@4HVw_;c>;Ti3uuNhTiwEpOi{>bzH>7V|oFBwrc^+03j2yKF1!B0O85PU(~ z;ALhVa8Q}$`kmkT9s7;n_zmYN3_K1RP98LXC&?4?K^%RE1IqpyoB)hG)4}v7X$aIu zT7cg{TjJt3Ws`@Q2Y3;^Mn6y=X}FL4!~x__hqnu6*A+YsZa_Q_oPkIKgzN=x1w@+2 z$FoR7*@Q^XHOiq6qHcg^>5zs<7v%*`0hh~jB@ks$2YpLD;F)&H1Am^SX~J{z!xvn` zNb?&YE%DJ7?$h}qEm!Iz4jjTMAT4E3E_o=2G~Dw%(vmLp6M=N(W(^rz>(j9J3v0-1KyO@p^ykcZ~{Wi=#-fQrW;u&24MsrV}`Kv9EBTeltGYa zJP0!#$ViWo0*V8nWy%(Tr4I50fq z6YFQtC`QF0m;`1X$VWNMBrx)d%}~)#-18R5fBH}V$ymX{NuV$21DJy8E$Uzyg#8sb zZq$cS0}qxOP~12W&^=~M$O{7oU%=oPSY|=!OUfi4{6HRna--jYU}Va|xn$ZI!wepu z{p@G$Q=j^jui>G*S^vX32f#f-{JvH|+rST;2+$9Hgw{9;v<+thJi$5of~5&I!ot7c z3jY&_6HQ;TEW_Id#4&q>fa7$N9{%PY=ZifiIL^ondqBv~k{&!mJLy+w#y#~y`)Gg2 z$Ga713p_#}VECCeqy6ZY=m%&M^dpdmej*=q$x@ zBl1T*I2=FoGe6^RpRu%uPJ<7T1L%(2vc!nq;|h-x4^U3@$=d<6g#ged?!gDT!9&o2 zD>?_c4%|W)1JFG9hjtK8ImAVL(3WSxdjZ7p45t`bL0u8{kSl(gwtH(h~rFr}IS{hznv5=8JJA~Ou33}Fx$3gl4+JdaI&G=$$MT4p2|#bLz4aM5_`#d!dt zBWA;E#s4HB=m%~H3U4q3-I%tA7Az%TcridINDK-y4A77L02r^J8wM2x7O#vlgMng$ z&P<0R$S6w!iWlWb-{6?Qq=6It3Ejaf=u1H1GJ2z4_PMYR0A&dyvG;{Eqz~pqX;Tik zlZHItj#EK;a0eICMjgTIT)~mO1aU%TOAM2&L{?Qj&Bjr+~t{7ruzuhr6jIDzmz z&I!Aw@4D+Qcf_cZsdyYTWCPp+&k*j}^n5o0_yf?KcHqpy=gi8n7vz8c@Bi(yG|&S+ zfi}=1_zB>5T#*m-5B&%IanP9gAwTKEr~#xWj=!d*v;m(3}}oXov2A_u=!X1K>Wc;Di3c(FS+;9a;vDr*XMjh&~Fu3FtZclz?Lo zt^l;89pHv6!x!WWdXX2JP$m#%6Cds4Ipr|ega6QnqzhZhJ%KvOhn}QP1|R4a;wUrH zb5Hto-NXUJMR^49j6Ms9^u+O;-_a%lKpyT%M@Y9Z>Uz`P&Y+SA5RwrvLWTFpJ%qtq z=i@B##sESL!_X-xC4Glq(%t($OTYxhT;Lw1__#n!Nl|fI?(E4lGxumYD>WDHfupq2 zTsX@5R90$bKBPEuWdd5RN^vFH!kLoVkKgNi{{!#WIp==8&wZ}@y6(#xQbd$TlmOHq zYx7vn9^pknYHxokw(EGjq+uN1RT!u%uBt3so;1E4MZP|=GkqXsHjVQccA1sU1{!o) zWfkCr<=}XEqBwp|9hnN@oc8sGqt!zXW?}-EnYy7?ad5Roi!+0{$Y7lB#$n=YpeuBb zp>EIDDiwxj-b-fOTEbqfkZMGqifBkOWM@XF&e{1gOic+OXYEVMkL&%ug2BhnMF$O} zPHK#t&;`MgS}LPKSeO}5$?&PTm@`|Qd;LhF!*q-%>t=p2^+D<2eN&ctEaq<0U!d%$ z;Z`jxgz8hH@q>maIoMKDiB$=G`}Opiicv{t^oTxRRM8^LueH_A(7sU3^2^1q0rTg0 z6Bj*O$H<>%3i5BUlw@n^r`rYIa}Ri+n5_K`Q&H(=5cX`WP&&dm*pO|ncr89n8il_1I1^^loY4@{rm?I`*nN$CYhD@TsA~V?;Q1ZYP4dY+sL0RX?l)WB_P7(F6#nmN%5{bOcV^3 ziQM z;u6qZul;aoRT)bCN{JPhVI5&g8}O-v=m>)Qz;{v$Wn5J$hywN)v2?$UXPa+G(WN#s z9yeG4m?KQRYt_!{USVp0$m(Hol1P$odl8*4&}pNkzn zDLr`5pC=zAjQ4$jil0-K?8=Y037fTcix#6`Y`RazIH$TaB}w%DJV<{j0o>#k%tC3h)qnaK5-9LI`o-60VCSvxronIS?tv^7WA$S^JAsTW)%> zHLO|TL4e1UkS?iCbV{TVuH6e+I(WB8$)`qpEiD|k{gDj6&Vns=8k3yG3V?qwG@Zn( zX2d>L2q9HKXn2t-_vbh+rX=HpDiW4=qwwtCi(Wyw6)$T`W6z@cP)miAa$NMDMPdsQ zCSr_RIjq!-Mh|mGLrq%G=_u&Jh=jju=k^`K*ORx!{&pfkSKO}2Hr~HHC^BVbqPzi% z$$+`7uqc4UBC&;>8Lk3XXa*&gJcsZ_^%s6u4q#z+Qj=*)i`{nEtFclTW#l4{nowdD zw}IrV%bRY?Hi%AVx)c!xbCQ6xfiqvsEsRpuZk*Yge%1gD*;^7A-Fj;f9>JKM%|o|m zOS_kzQ!1}=P;4fLsjZseK)Pi6lLo$YSnNlZ`a-9sijKy#KB3!8dLbQy;)C})cka1N zdOKDF7HqbbU4n6?lf9nvmoN(e(#Q{RqZBf2i6b6(%hQkBxhB39j$Pw!?+t$V-ya=E3wQheZYx!8@lH&zSV2APWLEy9)9sj@ zQkj>8FF#+U7EWu^XUhb#l=RdZZeThTI;R-q(jbwlj5U>qLeQBabz}@&?so|=(l5q1 zYolIGuglp-5)Kk|KDGnbgr+;C-We;&zHZujaISbM|7)6`YND~&5X2N^DX9o-6Ax8N zJy4W&HQfqYStA!l6ER2=C5ah#rqrwG#KlS<$d5XvMBd@plUmc$ZY0_6v5MPM_BV8v zUGIMwxMlpXaHb_M;e(YYJA`BF^>e_bdYwwbUX0BN347}xyXLAfNZnAO@nAo|*OJ_o zfb`BgMfZ2gDg7>S-jTsLKK$r$p|=A~a=ACl40&@#Enh!c%F9a%{ixA%y-8Ka*%E<0 zcnM0EuR82r{wV6{V@t5Sp=}dZb4^)pAM~*f*9*1i zgRBn&Lh6Pu>&4Wz(?lqA?=&CpocZG%jIH6CtB)W##B1r;JOh%L?IDP(G!tZ$60@rw zPJ)Gs*cL5Ol$hAUjY#GZLH*yEHdA6HAIA+CoHgU+&eYemKhPL)6{TFS0+KX-o=?%Q z)r~-qaLPnIqU49pL{|0;7HXS-mXrsZo$q}B&B)eQP1pB@^cfH+4}(f1g7D2LwKl*y za)d8d%kT%qon_b@BEbk`BC92`^FD5hl0E zgHW+IGU;wcsIY#SD?sm@ycu(0xICUWbjT0kX&i7L<)wVh@d=@>Yv|yg362%j1S+LA z;0W%`FT>c*5)Yu`*Eqg6vYO^tuUX?Q*A0=eg3^wvYR5AsNNI~d`zyR#bcJ82uj+jP zzT29)yHaOUQ^rBj-z5^^(G)A5{I;7>d>e+gs+QQB|O#@)(1+g}3uMq)gy;l2#iY<)_w0`@b_%O^Cg*CTTr5=wE zjUCNsq1$w|;0LAbC>Xe{jzB?HtD;b@>-o*b0^e*g`MuEBAU~~>a=~$@mR@>1*t+Md zgKZE|7aJAfUxH|7sXOIWLt~%p*XOrLDI*ief6`obkjXslSfOkKKTc~_bv8^;_VujO zw1R9%3_&}0UY7LUbVt?Id0}z#laK{$0T$4U!FLF>N8bo=mT;24Fl91l`vYkg`K#*= zQLzO0li1Hwt~T<$il|zm5!Vet;AJC3=$#szxJ{kCGEfYCWclAUEBj;pBT{%nc>FD;9fvtHQU( zO%iJ+4@#Ps$E7h^3;h_vLB;kOCJ6m}oR*IXhFzS@gmMqncj76}Jq{nf#BnD7hu zbwI(;MPB(DkVoklD3oxjWWS`%yr>P}>TF1-HU6lZVQjk(O}lIHr`{cP1=Xq|XoMQd=GX$1rn;mWe$D*-lN|ncK6zJcM)J=j zGihuG{$_AQNNuU~Sa)pandPWd!xl_pI$_|^(;lI}YGM|Jp&{G|Wo~B8J>3=G8E``3 zI)%Dw!v7jI>jey=s8P07O|~-t1;jJl=h#hGGs)ejOLDS? zy<{WdCRc3BYKGv;FG0KaljLDzV-u0o-|xcF3Z1YAX;JXc?zTO49X+OTwN5=YB=Q>) zcn+hI^2grltlQOvLi(~++?)})-C%l^Gzd^lWX+zZ@UbFoWRsNZE1A7mORmLTMp_8l z@MoOu(Oq@1-`?}rT2y|uToBYM#wTPz3)5O7w@hMDLn%ujzFq(q+NXJuYMn+WaUr+~t3(#%J znLIc}C6GyU+jwr4dxmqCbcxUx&JlT)sRMebEM%Q_j7iwTJUXO8bc&fmVQ?rw=q5!j ztn2v!(^cox|7^5mHI-+-!En^f_lqFRG z>`B*5U{EensK82)c6WNCsdF+N+d`O`hL4)K#Ex{oR{|-Pb?!apyG-5(r$hmIu7Crg zT2nl*wTwJN(5`52PE)AZD&(gy$-xSzK5p-@*CH4Jx~X4!pzv1rX(9x=i2jxjoU#x!F^Y|8;w14Fv3Q`H#t)}+YE|^lcAkRvy~o^0 zb2M<*d0c!Gbi-VE@B0@h+brd(cy`*IGF>|{4!pTeDMU`eO1_+BX%4CE7kRR5)CfTU z;^rTf_e(GLOY9WoO&T0F%m1{PvT;Mb(g@l7ZaXr_M>EA7wYV;qlp)}A)4*sK#mA-$P6`^k8r%2B9bEGp=Gc}mNiR-|6Em0G!mP6~Nqg@SeC(MeR<01b6 zRLRD{A3KcB4qe5GSWd=3hjOkt!f;0%muj^m0&z4PNKv)2i!jyw$cWT%d5ps-7Y$Aq zPWsUO7`n<~+R98hDd61TAvmSq7HTQmu~0?|B-nE<(D^mn&+UU5^R&;EP2 z(*84-P4+3eKaX;1SJXR9OYJ~~PWp|j6s^so+sl@u#LymD&A3jf-g+0V9Cw)NTym_V zNl|vRzeTCyVcvCqK8YoE7sSditp8Mb{}_M2@&L!#r0#F1Sj!=@USkWP5S!LB!@I@Qz&I^dsZ5`J6<)VQ8$* zl)ws;4jASM(aF_p-P;{B_wRb}b_>|NWKre@5yp+M?L@bnZm@#|Z0{iR`@wqi_C?ZP zx=tnz>C@D3sUO}+DTq5HPe5V}jkA*k410|&gez>5A{KP>cKd;S=u%PfqaWq{N^26v zl5cFkd_QB^QR5KzDEB4f4AEmZvx)`yWT_aO7$=H+mQfG$h(-UQI0YW70^~>Br)e2* zF!5zE2<^y!uvu#p1Pf1&b#lgK(6sRG&V4f>GdbKLqBvZA#-IG0L-DJ?XQMFCZj)~A z{1X)x++@@;))!UfcC;CRCaqc6gK`0NS@Vq0%9fHWx1MMuXdd51qrXE0G>yUL=hA7U z2*U{m$SOrE6;Rx(WrPkQS>HzD?sGD0YX}1(KS{7_CCH$xJMeXJ?vR0d6!VC&36euL zC2Bh^tN3zcyBy_rFs49xe~2?nyaJ;`7MTKzwkYvA9|>tzlbi!iC_pJ%1G4Di!$$9u%oF=Vv8#Q)-6z|hCJi0dt5o)%<0ZG5mpG?JQ2un zZ&Bd;w$emIr3dr3ZdMr*KzaM3^UNcXS9y?>uG-J8a8mgb`C7!BDiYxHxdD77!fA9* zkR{93WEW6NW*<&H;eGKQmiiOt!y`7Ov#neheO$n{T$S~tn)`0>o?=KDk*ctp`&juC z_uvL{P~o-h<&lD(|AbPn0b(K3J(|9*8IkvAMo2cFLN%HSh64``oixrg+p3QuIio z$!Z!NpN!Z^jpBgqT%hTtKam-2s)hH`2a=bZka!fYHB-_vWYRBlY z?>P)YAVID!@wTU{Wqqf(ir8R=vlSedN{#aMDwXf-mV@}BOGHN>!~}Aj%l-7L+%*); z&qOvX_ayRVe{(E8iA#rO+^7}hrh~x*t20x1MYC|63HXVhp`Ec*;z5bzL?Z}0YEvcJ7Ze>Iu|2vq_@10}O$Y($^Xj9Nm*fObkd>IwybOwsn9 zhvZ(@4Q$*7U{~=ecMhkl#XT2Ea0jy zAr>N1yDe&Tr*hjh3SOB+^hR(}r%N&}Wjr0-lZgQ~W{Qr3bYBy~0m=p{%Eo}juKtqu zF7nrM!GPzH2YmI@Z-?1W7Yej#0C2u)3IKJ}dCv4q!SCJ4>OD^n1n$U;LkgqQ*LyiI z+ew3$WU64*&tE%CsA}QJLG&UjWJnd`!do=91hzZyXN@Q1tVM^siLf{S z{hGPM$Hin^B_?bj3t%z9FX8cwb3|1C0q1{~Bh@3OB5va^6A#y?4P(z%js7j^quNs| zmOr@;TKuVQIq~`wDoO%%>5yWjMM^LaOYipu?T|5q5gR=VwOE2AJ~7Ko#WW4Mae|(j zN5>1srpCSk(?w|Epsv?hcG-nmB*`{mA64W|+L)k-2ELJ@+X7fxpWROZ1Ok6X;)2V# zgEtA5Ew-!XV(hJz&9C)Nns3G7aywD{5}@4bDyd396cKBHnI4aii3P-MzmJB7FK8%S z%}nsK+jcVL=`81f1u&Wt^Evo~@RKyr@tcYs2h7KE;Fi((*Of~%ZB_cIyKX61Q#yW3 z4^Kz?j`(5A3JS&LBjM&A#;G^-Q}b0)BT(AL*=;Gl$45|h%E)ot?Omwr_s{bYoPL%# zbvUOxA^`rddbnnuOC~w3+!GzFA6-3&2WY?cHE`7@)#)1JA`VdIs~CwrHt_r43vq{j!n?zHM)p2(-w8|1t!Ggu-;V zN}-v}>4c2t0g5YHk?Z^?Qj+U%|NZMeynyuhuZ&->( zTtmfVv-x(KId9{lYj0WI--z=8>pD!V3@p99uG)1Ckgzoh;JK5%UUDL1W$vceSV1}0 zLK)iP8`eG}O*#Je5gW&_QB~|$+J|eaxeg=kayv(25P>;W&ku2fF+oZrsO*O6SZ6*b?U@v0u+=L&s`jyfB9m2l z)%FXgw>i|EBOTkj{!GEmc|iq|-wK*|6e_X03&rN?14G5*)RdO%%(_f~NslFfwaR94 zsX3uo+stl*SXdbYTjr#XzHEE)@xQ7c&`OBIAjj7zEjUFS2?C<>n}Bnz8mvz50Xx!i z>%r7*Hr^ZTGU7+hDJ)pT#62)c*|zU4k@$9o>Vq35qE?Tcu|3KGS-%O(z6vZ6g7`q> z)j_ryDF=JX{94j21A1~KtWu7;m9zlUI*;&{s@AaOWK!(g^;p}ZL|wG5?Q`l&hFsf? z+x%s{3ZdXbq1Rc}nkIwFI>e|oS%S5I3mNhY3cWe0v~>-2?I>J+21%AnHLo!*kafm6 z$Pyn6Ma*L7OXsq?tXRT90Mp2c2rTD1LS{ErVsHd*po-i$Y-&Kcsx2|=ZC(95!A?;= zyn;OTCsMXQ58SV>A}W%AWQhEd=I;|}3N2ZvwOe1WK($$bBnS=Jlv9CwL%nG63C+Ht zA!)P`l9O&KK6DZnkn7X+8g6RIzedmrgJq(QiY;XmW5AKxKX`I?LBt>gqF-WT&TK=2 zF1rR`qyd5eA1p8>4rL##yhdykfF(}yOna->lyfD0(JSgYOhDs}-{)1P#8I*7!(gKo zkW!VK24#k0IaXJ#qB zNRH*@h?DB9B@KWXBAg*y6Cx8>I~>!XZ(M)*b^w~PW=3F^1qZJbzqD(xOG+^S-pK>w zRvgRyDoT-Lj6aoj_QU%jFmMX5W?6}rkViBU!7QPSoSItEHK?<9XI{In7(|IvBf8~< zJFl)t!ez_98+!iec|9;6TdZ&SoUA8lWGner#B?IVG5-Eu9F!HjCf+Y|soE*+nm*ZX z3i(AqY<+iAC`9tv#l9gV$)>ziF{Dl24q;4?$O?GxC~xO0LBkuQR^y{e21ZctqIfcU zod}@>IfwY6lh0!;NW;XKzJczCLF(yY@$wxEBmQ0kFjQ<%KNgLlbd=6o6qH?Jgj!se zC(7$=fD(jV)MUiDYJ**jSp&|*Y-=?0uFoS5lBNH=G5 z@m#7EG<-h2Gm9k$-#Ltow0M_orIc<_ckc9H_w%5wf^AkqeO?mc3xJ1MN{byyw|XDF z=57a3=Dj{eKI^8mm?{`4;rmsLPs^H>_%1lq6nDPt&M5hMsE4srI#wfe(3EjxN?ssZ zqWluP3ya2%8OhUBP};J`vwPV)kU3@5Tq(6|PB$HEQ;LA8RODclXyAdYsPs->M}+!n zi7@+hrUc{Up&)->O|_K}4EXT7{(SIx;)6_NHQIZwn+$D?MB366OOSOJw+`Y!Z!sV} zDgcV6IjHFwWxd3j2z;MznGAN0-SXHP=w^f_q51>Zuv~{jI`o|G0yWY$%Nc)eBC*8SpdiW zT`;rsNKT7IVJnEu{B_cP9-(A;y+Y5&FW3c1u)#{%K2HcP6W5_1Gv+#)GuF}9N9GJT zfQXE0eBcJ5S(GB$>nO35I_3NzSH$(=Nc0Gq_)f~@hpVXf$iPypR zB|TM5cb(cMf5d8W<8@ScX{TE9w47y}Axk)hT}z?S+0!r6Z}Vh_F&-n&Tr{?R)Pn)0 zSR;kLBYcGhYSax8K2Uazv{s|WVbgS&Tp50;ersV1(ZnLTs$oYgdxqR21i5T3+Aq_R z8^4o}=u%?kAGZ45JM(vM=D&Q4pAXADZMZo@wkESPt%0jr6$ z{uo)Zv83K2KBHiE!3adwI3a)o#nkLbcc6`FX(EbEWebC)eE~$yiRz zfT|UVj!sY2XXJ<76-SWebWzSPJ zrqm?Q%fRGPxp;j1qR~6O4e?75r4h@3DR5$BW_Fr0Cv{xmQix`*xIV~HJ{CV$=ZMe( zeim2dOSLui0M9OmD?8=Ee@VkFGVZsCO_BA2(KHMR{%qg*`c8f-1NGcODht_A7Nl1K zjj8K!B@xfC4&F<(TvANx#Www=v}Zr`TM!CKZx?6*g^!E+ScefLKz~QfXHTBfGR_(S z)&4Er?6#k$D+j3jsz02Lux{6)#KiT3^hI^H^AkprMi}qnQR#E&}zSQ zleIa;JZ@QuPZi|c4V@9TOTS!;?X~(K+19&P-+Tcyat~~yjt04AR0SVJrojtqJlJ=L z_lJ~=pr#Phi1=B(3jBsR3Jj_6f}Uha*xlKm)xig6vCO&7F$jW630Rk>pCDku=myo= z`F|~Iq4L>A^& zR@rQ8bRtzH-m8J@Kx(_iyC%-0=-1U?okwQBSVqj@hh`t@$8RT3{Z}q;6op1~M>Dlk zwTo>oA6yXi{CX2Zg8Cp-PWD~>?)=G=53u+rr}n|QrUy~4+N5)>rg~qvevCv%bRM;q zuZK?h+l15%_Vua!!bLxGYg|}|mg;w>)zr1WI!bS$3_~OOi@ST-N?9gHSD!In+r@Hk z_FJA~5G%6DEtC<`hbfrdHg=AN{DZD_4(@v-|cVN zz14Q9<5~LiNZvW|#L2&_^L@n{eV;1B)6gfUzk3_KzUv#i7P|9kd;e?JxnUkQZZuM1 zVWo>{rC?B^8t|4*WKr=MKgch}9~0JlO#1eQ7~A~KZ^wWwjlWE%-``97pjY`_rQ>QX zb)Zh3$~JXX+90S6m!4q`ZjAD;(tf{jwtaFMBl~U2_@&`$l-Y#Uu=38GSbnDGrk1e zJ$HL!utxg)e2?jH-4i~$=q4be_<^1O?hW{ z2jO=A1*KNd?U3X(?&(K?5!1ET6sN{}Go71fKDu3gGvz)Uc;Q3R+6lX)CTzg7z zgrtcm$PQ|@e144(0q2>^<~4179KB7GpE2};0w*mVo3%@4@B_y)`TYOskU*X{|4a!z zeiJyRv^n6czOzPcPN-Q`6_Fi0;}c!uXc_=nxEUhv&Ko3MXf^@{@$Xm9eLaEkN^}<8 z8O&EW`T_4_C@0(h?7Vg(rr3_W{=mHCj*(FoCT&pePWMNP)L|9wlku7XD7*0m)wwLu!j_q8nlX0w8hl<;tZ-Q9`unL@ zPwQWtRr<-U9=BbWqUI#pKa2w#Dt>kF>eysAw_M4-y)@eV?F{sPc>U=yS-rA#1{x13 zasN*DZO>>9#%S~wY9pYV=mWhKZgW3;{-YWM1>!x1u^Uv;MY=`1`}1>XqpcKUg!rG- z&vb{cEw9DDqASYvNbHGuzUWr%1xp{X>%;A}txv@N@sR00+o_)SNLC_&n*;g~DgLdTqm@H-l43 z-dZ!~Ml?67+zpFmuzD_2wZI=I`+6MPQNM1DQ;UJMS<*F2WzusVj?iUk-xe0=woLUM zS5Fd4WIJy)JkqY1w0=~v$Z>6zcvEW%w>)RQoTu@tDx=c={^VC@amXZ0;H=em**ah^8;k4irVi#)BhUqxAk$ z_upNmuR0TH@?Fz|0vFHhNX{8T^!IR!b;BjGZ?GCCGHWeQ$SXz5c

ip}po%Jo}d358wVouj>{{iw86=p`jiUw^RcpWc)j6+NbGz-LTI2dRVMl<6kxeI!jt-Sqjdh;oMjMKA;F-gQTE3jX#y$Ho#(U!MGgpSaRSG`Wj zTk)5Bjgvn--yvx@CtiVI&ExTRrz|{VE8ot)HYSki@2rf(&*B}sAH);%P8#B?$D5?= zTKxTfgfA{UHTQp@?M3!z;}2?>Tg0y+MvCn=YTE8SQgXTyQW;{M7!?smXev`U*eEBT zE=lhmn&spV&enXc;V>e9{rowtL_U$V$^pcnV&`eu?181h51DBrD!+UtXYQd0C;C$q z^GTUP*B%+7(V~lA11p*4Z!26P&^O#Vrz!3du8X}PgCn@%-r*0`qEKiRIO+pFX=F5B+)V{~IGx(W9HGaUFM zKG*-=(8r9gA;aJ9h3@^&u6n`_q1kk!9F|F8z+HpGW!=|W8+`*O%U4W9sO9UlNA>}} zGy0Dg>WmCYDE>S5fM4?ubuO7W#2OCWS(UB;ZlfR33;zF!*g{Iv4ZnYPmAmHjwB=jR z({iPMv|dg+#U_jsA1@zC*$S?DSvVy%2xES%)VJCkuCdbE-`+qBq&io;MP5KOPk)zr zP)%K3f9!4C#@DY_X&a$EZWC~R%n4K@w3yr{^{Xj(2t9+yTG6_EbHbg@};9h%`n@Hgxyy2TRGA5#(|HP$g0&7-Ud%!)oy~{jv^-kV(;-U?&8z6lYP-~n{J_>kK3(l+fN6E@21>I`Sayd^9AHIVSP!b z8qcuzMV3Hg&w(GG^-v1p~O0#n>m#+j@q@1jF1WR0Ga=mXVmF1Ar_ zEA)d~l}WAsz6J~b;$QZXsl(ev1MkwzaMrOB-Zyxsx1k5}Ik`n{wN+aiP95mrsgL0S zOu{ksrIgjOwT{F4pE5icz9KYig(E7!Ah%-o*Xrkb_|f$`LHj(BJ-e_Iua}V0*_x7c*MWGP~NOi zx@Jn)cxUk-y!U?--u0}FkH8Tx8?`SJgVCpLMf?~2!>gKLy=7$XZ}AC4``2ia zcaZt=GSE!0mDhG(I8=?b?P6x?_HKEsk%NHwYyW;-QGeBn7h%L7X{A`exYz284U zr2cz=db4&yGb)Y>3!nIUqw^huhJH&G`FR!Z0?o+0(}ua(JHo%9VE;#%4eKw_d7A@^ zw>1#r7Jk@4O~NXt7c?(K4ef~E9QUDRpN)JFut&(n`d^qv?+(zz@;=Woh`w-KE1K7M z$}D+*mw89;oO9Fqv(`D~;+HMm(TsPTaan&`vtJ-4#T2>u==9k=UmRK^Kz*BcOJ=l` z*S`&SSF{=?@=x6hS5p-J`(sGU7bm-WRCpHqR2aHkRad#V7Vop%l~8p>qgMRROP1el zDG#XKA|bO(w7Bmc@%+4Z^(lIZSo6jENw-D@2sx4yhrw|=Nf1%O%_tujiPngN6v5zB5nRY=LQqgpc z_u_kQdv;wVPhX$2xUrhFT2RA0%^RXU8;)yPaNi>NO*d+uRN6`uOf}tjq{fs~SPXsg zFht>Wa=zw%?3FJ|D?NRT&s)=skMmD>4(c5|-$338o&l%Etcsu7QJ9nXAX`!Au=C|T zc2_;E?FsBH1&wZtXpu2$}8g_XFs>kB~4le$>|09^WwBt`c7{2 zJNNjKvcf}&fyd(Jmj6kM>V9|a;1t)&Vz7#mB4#`Ttyx!hSv~(w&ZM0#7kj=B)?1?K zb-c=-2~LrjOmrM7DjmmsOCn|=vbLNCtA^-$jpDI~cl8~7(Yyn#TW)urcg6xT!8>sm zyhDmUw%#y(3Q2631W#JjLVI6{VXwB zrBJ*RMRUX)DrtNK{1IB{kuJY_IO~^GiNHnOf9q9Zgl7L~?FTM~gJ*mt(I)zQXUeq< zAWh87EXcccizk9y`-9PVDiTy!xX<73`8WHQ;_fBnd|xx|*IrBo@M~Mv>RVTKa&JaQ z<4P`s>epKp8RFY{WG_+YqMCoeycABX<>f>|Qu_Oj)xW?+Tesjtu9lV_z_EaD21Xqu zl^~N*mTa%uln;@%wQ5}?Y>Hv$?5(|hA;JXJr8YogeRzR1+|8aUj1~fZY3+0XGpfTwrOr}3oq8B zl_b$w2NsK&(e}er4@I+Y2}`W8!vb{9h&!$Q%WO_yx|VOkSgs32;$6{}tKIcZV}epc zj=OnIwtA{^^KI%@$-gorjioblzt!G67sP6e{cBgU<}=CLO_Jgni${Yr&nY}70rT5F zHt^hiL)jbNdnNvZ;UgA5<`!k^+HPsMZ2nK{)HRz8RN;)@mW%x~2yH~4)409#kIiEr z+oG1JreotBE41P<=XLQXq-BJ}B46kBQ&XIC;G_LI-|MP|f|$l&Yf5Tjm<4}EN<11P z<20MA*!7T)5+oTrqx{?{)FY#H$b%%6AdJ$+_U*=Y62-DHKlbez{)tdMMU420$mY4C z%ra*~yxH2O%3aOE{Q*6(vp(>}wQuHL9&%)LR}V8@pd%*zRj=XyeNF{iF6orp7ioUW zuP-@9!Uvfev!ag#&N#t~T$hZowD8I{KV+4y{`&2`nS-CQCj=3@6dT^R`{vfto4zIa xjx&qN7v?Sg-t?0bm;1l)pe*ygjXzD^7otD=r=K+__Z1+BV>Zs#jh4Qt{}0OH6Kenf diff --git a/docs/src/tutorials/basicUsage.md b/docs/src/tutorials-old/basicUsage.md similarity index 100% rename from docs/src/tutorials/basicUsage.md rename to docs/src/tutorials-old/basicUsage.md diff --git a/docs/src/tutorials/distributedProcessing.md b/docs/src/tutorials-old/distributedProcessing.md similarity index 100% rename from docs/src/tutorials/distributedProcessing.md rename to docs/src/tutorials-old/distributedProcessing.md diff --git a/docs/src/tutorials/processingFCSData.md b/docs/src/tutorials-old/processingFCSData.md similarity index 100% rename from docs/src/tutorials/processingFCSData.md rename to docs/src/tutorials-old/processingFCSData.md diff --git a/docs/src/tutorials/whereToGoNext.md b/docs/src/tutorials-old/whereToGoNext.md similarity index 100% rename from docs/src/tutorials/whereToGoNext.md rename to docs/src/tutorials-old/whereToGoNext.md diff --git a/docs/src/tutorials/test.jl b/docs/src/tutorials/test.jl new file mode 100644 index 00000000..20c64d0e --- /dev/null +++ b/docs/src/tutorials/test.jl @@ -0,0 +1,4 @@ + +# Basic tutorial + +println("test") From 0dacbcc6b614f75625ca5e766438ff7adaf3c140 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 14:15:37 +0200 Subject: [PATCH 22/61] clean background --- docs/src/background.md | 140 +++++++++----------- docs/src/tutorials-old/processingFCSData.md | 10 +- 2 files changed, 69 insertions(+), 81 deletions(-) diff --git a/docs/src/background.md b/docs/src/background.md index 4c737085..a169a439 100644 --- a/docs/src/background.md +++ b/docs/src/background.md @@ -2,99 +2,87 @@ ## Introduction -Flow cytometry clustering for several hundred million cells has long been hampered -by software limitations. Julia allows us to go beyond these limits. -Through the high-performance GigaSOM.jl package, we gear up for huge-scale -flow cytometry analysis, softening these limitations with an innovative approach -on the existing algorithm, by implementing parallel computing with stable and +Flow cytometry clustering for several hundred million cells has long been +hampered by software limitations. Julia allows us to go beyond these limits. +Through the high-performance GigaSOM.jl package, we gear up for huge-scale flow +cytometry analysis, softening these limitations with an innovative approach on +the existing algorithm, by implementing parallel computing with stable and robust mathematical models that would run with an HPC cluster, allowing us to run cytometry datasets as big as 500 million cells. ## Multidimensional Data Multidimensional data is a term used when combining four, five (or more) -characteristics simultaneously to create a data space where each measured cell parameter -becomes a dimension in the multidimensional space.\ -The data generated by Flow Cytometry, Mass Cytometry or RNA-seq are good examples of -multidimensional datasets, as they combine all information from all characteristics to -create a multidimensional data space that preserves the integrity of the relationships -between each of the parameters.[[1]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6092027/) +characteristics simultaneously to create a data space where each measured cell +parameter becomes a dimension in the multidimensional space. + +The data generated by Flow Cytometry, Mass Cytometry or RNA-seq are good +examples of multidimensional datasets, as they combine all information from all +characteristics to create a multidimensional data space that preserves the +integrity of the relationships between each of the +parameters.[[1]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6092027/) ### Flow and Mass Cytometry -The use of flow cytometry has grown substantially in the past decade, mainly due to -the development of smaller, user-friendly and less expensive instruments, -but also to the increase of clinical applications, like cell counting, cell sorting, -detection of biomarkers or protein engineering. -Flow cytometry is an immunophenotyping technique used to identify and quantify the -cells of the immune system by analysing their physical and chemical characteristics -in a fluid. These cells are stained with specific, fluorescently labelled antibodies -and then analysed with a flow cytometer, where the fluorescence intensity is measured -using lasers and photodetectors. [[2]](http://clinchem.aaccjnls.org/content/46/8/1221) -More recently, a variation of flow cytometry called mass cytometry (CyTOF) was introduced, -in which antibodies are labelled with heavy metal ion tags rather than fluorochromes, -breaking the limit of multiplexing capability of FACS (fluorescence-activated cell sorting) -and allowing the simultaneous quantification of 40+ protein parameters within each single cell. -The ability of flow cytometry and mass cytometry to analyse individual cells at high-throughput -scales makes them ideal for multi-parameter cell analysis and high-speed sorting. [[3]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4860251/) +The use of flow cytometry has grown substantially in the past decade, mainly +due to the development of smaller, user-friendly and less expensive +instruments, but also to the increase of clinical applications, like cell +counting, cell sorting, detection of biomarkers or protein engineering. + +Flow cytometry is an immunophenotyping technique used to identify and quantify +the cells of the immune system by analysing their physical and chemical +characteristics in a fluid. These cells are stained with specific, +fluorescently labelled antibodies and then analysed with a flow cytometer, +where the fluorescence intensity is measured using lasers and photodetectors. +[[2]](http://clinchem.aaccjnls.org/content/46/8/1221) + +More recently, a variation of flow cytometry called mass cytometry (CyTOF) was +introduced, in which antibodies are labelled with heavy metal ion tags rather +than fluorochromes, breaking the limit of multiplexing capability of FACS +(fluorescence-activated cell sorting) and allowing the simultaneous +quantification of 40+ protein parameters within each single cell. The ability +of flow cytometry and mass cytometry to analyse individual cells at +high-throughput scales makes them ideal for multi-parameter cell analysis and +high-speed sorting. +[[3]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4860251/) ![MassCytometry](assets/masscytometry.png)\ -*General workflow of cell analysis in a mass cytometer. The cells are introduced into the nebulizer via a narrow capillary. As the cells exit from the nebulizer they are converted to a fine spray of droplets, which are then carried into the plasma where they are completely atomized and ionized. The resulting ion cloud is filtered and selected for positive ions of mass range 80–200 and measured in a TOF chamber. The data are then converted to .fcs format and analyzed using traditional flow cytometry software.* [[4]](http://dmd.aspetjournals.org/content/43/2/227) +*General workflow of cell analysis in a mass cytometer. The cells are +introduced into the nebulizer via a narrow capillary. As the cells exit from +the nebulizer they are converted to a fine spray of droplets, which are then +carried into the plasma where they are completely atomized and ionized. The +resulting ion cloud is filtered and selected for positive ions of mass range +80–200 and measured in a TOF chamber. The data are then converted to .fcs +format and analyzed using traditional flow cytometry software.* +[[4]](http://dmd.aspetjournals.org/content/43/2/227) ## Self-organising maps (SOMs) Self-organising maps (also referred to as SOMs or *Kohonen* maps) are -artificial neural networks introduced by Teuvo Kohonen in the 1980s. -Despite of their age, SOMs are still widely used as an easy and robust -unsupervised learning technique for analysis and visualisation of high-dimensional data. -The SOM algorithm maps high-dimensional vectors into a lower-dimensional grid. -Most often the target grid is two-dimensional, resulting into intuitively interpretable maps. -After initializing a SOM grid of size n*n, each node is initialized with a random sample (row) -from the dataset (training data). For each input vector (row) in the training data the distance -to each node in the grid is calculated, using Chebyshev distance or Euclidean distance equations, -where the closest node is called BMU (best matching unit). The row is subsequently assigned to the -BMU making it move closer to the input data, influenced by the learning rate and neighborhood Gaussian -function, whilst the neighborhood nodes are also adjusted closer to the BMU. This training step is -repeated for each row in the complete dataset. After each iteration (epoch) the radius of the -neighborhood function is reduced. After n epochs, clusters of nodes should have formed and as a -final step, consensus cluster is used to reduce the data (SOM nodes) into m clusters. [[5]](https://ieeexplore.ieee.org/document/58325) +artificial neural networks introduced by Teuvo Kohonen in the 1980s. Despite +of their age, SOMs are still widely used as an easy and robust unsupervised +learning technique for analysis and visualisation of high-dimensional data. -![SOMs](assets/soms.png)\ -*Example of a self-organizing network with five cluster units, Yi, and seven input units, Xi. The five cluster units are arranged in a linear array.* [[6]](http://mnemstudio.org/neural-networks-kohonen-self-organizing-maps.htm) +The SOM algorithm maps high-dimensional vectors into a lower-dimensional grid. +Most often the target grid is two-dimensional, resulting into intuitively +interpretable maps. After initializing a SOM grid of size `n*n`, each node is +initialized with a random sample (row) from the dataset (training data). For +each input vector (row) in the training data the distance to each node in the +grid is calculated, using Chebyshev distance or Euclidean distance equations, +where the closest node is called BMU (best matching unit). -## Implementation +The row is subsequently assigned to the BMU, making it move closer to the input +data, influenced by the learning rate and neighborhood Gaussian function, +whilst the neighborhood nodes are also adjusted closer to the BMU. This +training step is repeated for each row in the complete dataset. After each +iteration (epoch) the radius of the neighborhood function is reduced. After n +epochs, clusters of nodes should have formed and as a final step, consensus +cluster is used to reduce the data (SOM nodes) into m clusters. +[[5]](https://ieeexplore.ieee.org/document/58325) -The high-performance [GigaSOM.jl](https://github.com/LCSB-BioCore/GigaSOM.jl) package -enables the analysis and clustering of huge-scale flow cytometry data because it is HPC-ready -and written in Julia, prepared to handle very large datasets. -Also, the GigaSOM.jl package, provides training and visualization functions for -Kohonen's self-organizing maps for Julia. -Training functions are implemented in pure Julia, without depending on additional binary libraries. -The SOM algorithm maps high-dimensional vectors into a lower-dimensional grid. -Most often, the target grid is two-dimensional, resulting into intuitively interpretable maps. -The general idea is to receive huge-scale .fcs data files as an input, load and -transform them accordingly to enable the analysis and clustering of these data -and automatically determine the required number of cell populations, -and their sensitivity and specificity using parallel computing. -In order to achieve this, GigaSOM.jl implementes a batch SOM algorithm, in which -the weight vectors are only updated at the end of each epoch, and the BMU is calculated -using the weight vectors from the previous epoch. Since the weight updates are not recursive, -the order of the inputs does not affect the final result. In addition, there is no -learning rate coefficient, which reduces the data dependency. This means that, -compared to the original one, the batch algorithm has faster convergence, requires less computing, -and it is the only one suitable for parallel computing. -Parallel computing is the last step of our package implementation and two possible -approaches exist for this kind of computation: the Model Parallelism and the Data Parallelism. -In the Model Parallelism approach, the algorithm sends the same data to all the processes, -and then each process is responsible for estimating different parameters and exchange their -estimates with each other to come up with the right estimate for all the parameters. -In the Data Parallelism approach, the algorithm distributes the data between different processes, -and then each process independently tries to estimate the same parameters and then -exchange their estimates with each other to come up with the right estimate. -On this project, we use the Data Parallelism approaches because our nodes grid -is too small for the Model Parallelism approach. - -![parallel](assets/parallel.png)\ -*Data Parallelism vs Model Parallelism* [[7]](https://www.slideshare.net/JunyoungPark22/common-design-for-distributed-machine-learning) +![SOMs](assets/soms.png)\ +*Example of a self-organizing network with five cluster units, Yi, and seven +input units, Xi. The five cluster units are arranged in a linear array.* +[[6]](http://mnemstudio.org/neural-networks-kohonen-self-organizing-maps.htm) diff --git a/docs/src/tutorials-old/processingFCSData.md b/docs/src/tutorials-old/processingFCSData.md index 707bcc56..a3bdb2cb 100644 --- a/docs/src/tutorials-old/processingFCSData.md +++ b/docs/src/tutorials-old/processingFCSData.md @@ -58,7 +58,7 @@ e = embed(som, data) Usual experiments produce multiple FCS files, and distributed or parallel processing is very helpful in crunching through all the data. -To load multiple FCS files, use [`loadFCSSet`](@ref). This function works well +To load multiple FCS files, use [`load_fcs_distributed`](@ref). This function works well in the "usual" single-process environment, but additionally it is designed to handle situations when the data is too big to fit into memory, and attempts to split them into available distributed workers workers. @@ -71,7 +71,7 @@ a variable name that is prefixed with a `:` colon. For example, we can load the Levine13 dataset as such: ```julia -datainfo = loadFCSSet(:levine, ["Levine_13dim.fcs"]) +datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) ``` Expectably, if you have more files, just write their names into the array and @@ -89,7 +89,7 @@ using Distributed addprocs(2) # add any number of CPUs/tasks/workers you have available @everywhere using GigaSOM # load GigaSOM also on the workers -datainfo = loadFCSSet(:levine, ["Levine_13dim.fcs"]) # add more files as needed +datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) # add more files as needed dselect(datainfo, Vector(1:13)) # select columns that contain expressions (column 14 contains labels) som = init(datainfo, 20, 20) @@ -170,7 +170,7 @@ panel = GigaSOM.DataFrame(readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes= After that, we can get the parameter structure from the first FCS files: ```julia -_, fcsParams = loadFCSHeader(md[1, :file_name]) +_, fcsParams = read_fcs_header(md[1, :file_name]) ``` Continue with extracting marker names using the prepared functions: @@ -196,7 +196,7 @@ Now we have the vector of `fcsAntigens` that the FCS files store, and list of it to the desired antigens and transforming it a bit: ```julia -di = loadFCSSet(:pbmc8, md[:,:file_name]) +di = load_fcs_distributed(:pbmc8, md[:,:file_name]) ``` (If data distribution and parallelization is required, you must add parallel From 42fb8e9534c43e46a39fcee58762034a4a2141f7 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Fri, 22 Aug 2025 14:20:46 +0200 Subject: [PATCH 23/61] fmt --- docs/make.jl | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/make.jl b/docs/make.jl index 7a19b42f..e36c92d5 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,7 +1,11 @@ using Documenter, Literate, GigaSOM -examples = - sort(filter(x -> endswith(x, ".jl"), readdir(joinpath(@__DIR__, "src", "tutorials"), join = true))) +examples = sort( + filter( + x -> endswith(x, ".jl"), + readdir(joinpath(@__DIR__, "src", "tutorials"), join = true), + ), +) for example in examples Literate.markdown( From a55830f109111215d55d639285fd56da5d814163 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 11:49:56 +0200 Subject: [PATCH 24/61] try one tutorial --- docs/src/tutorials/01-basic.jl | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/src/tutorials/01-basic.jl diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl new file mode 100644 index 00000000..cfb9277d --- /dev/null +++ b/docs/src/tutorials/01-basic.jl @@ -0,0 +1,97 @@ + +# # Starting up +# +# For the installation of Julia or GigaSOM.jl please refer to the installation +# instructions in the +# [README](https://github.com/LCSB-BioCore/GigaSOM.jl?tab=readme-ov-file#installation). +# +# ## High-level overview +# GigaSOM provides functions that allow straightforward loading of FCS files into +# matrices, preparing these matrices for analysis, and running SOM and related +# function on the data. +# +# The main functions, listed by category, include: +# +# - [`load_fcs`](@ref) and [`load_fcs_distributed`](@ref) for loading the data +# - [`fcs_column_metadata`](@ref) and [`getMarkerNames`](@ref) for accessing +# the information about data columns stored in FCS files +# - [`dselect`](@ref), [`dtransform_asinh`](@ref), [`dscale`](@ref) and similar +# functions for transforming, scaling and preparing the data +# - [`init`](@ref) for initializing the self-organizing map, [`train`](@ref) +# for running the SOM training, [`assign`](@ref) for classification using the +# trained SOM, and [`embed`](@ref) for dimensionality reduction to 2D +# +# Parallel processing is perfurmed using the `Distributed` package -- if you +# add more "workers" using the `addprocs` function, GigaSOM will automatically +# react to that situation, split the data among the processes and run parallel +# versions of all algorithms. +# +# ### Horizontal scaling +# +# While all functions work well on simple data matrices, the main aim of +# GigaSOM is to let the users enjoy the cluster-computing resources. All +# functions also work on data that are "scattered" among workers (i.e. each +# worker only holds a portion of the data loaded in the memory). This dataset +# description is stored in [`Dinfo`](@ref) structure. Most of the functions +# above in fact accept the `Dinfo` as argument, and often return another +# `Dinfo` that describes the scattered result. +# +# Most importantly, using `Dinfo` **prevents memory exhaustion at the +# master node**, which is a critical feature required to handle huge datasets. +# +# You can always collect the scattered data back into a matrix (if it fits to +# your RAM) with `gather_array`, and utilize many other functions to manipulate +# it, including e.g. `dmapreduce` for easily running parallel computations, or +# `dstore` for saving and restoring the dataset paralelly. +# +# ## Minimal working example +# +# First, load GigaSOM: + +using GigaSOM + +# For the purpose of demonstration, we will create a small random dataset. This +# code generates a 4D hypercube of "size" 10 with gaussian clusters at the +# cube's vertices: + +d = randn(10000,4) .+ rand(0:1, 10000, 4).*10 + +# The SOM (of size 20×20) is created and trained as such: + +som = init(d, 20, 20) +som = train(som, d) + +# (Note that SOM initialization is randomized; if you want to get the same +# results everytime, set the random seed before initialization: + +som = init(d, 20, 20, seed = 12345) +som = train(som, d) + +# You can now see the SOM codebook (your numbers will likely be different): + +som.codes + +# This information can be used to categorize the dataset into clusters: + +assign(som, d) + +# In the result, `index` is a cluster ID for the original datapoint from `d` at +# the same row. (As in the previous case, your numbers may differ.) +# +# Finally, you can use EmbedSOM dimensionality reduction to convert all +# multidimensional points to 2D; which can eventually be used to create a +# good-looking 2D scatterplot. +e = embed(som, d) + +# The 2D coordinates may be plotted using any standard plotting library. In the +# following example we show how to do that with `Gadfly`: + +#TODO +#Pkg.add("Gadfly") +#Pkg.add("Cairo") +#using Gadfly +#import Cairo +#draw(PNG("test.png",20cm,20cm), plot(x=e[:,1], y=e[:,2], color=d[:,1])) + +# The output shows all 16 ($16 = 2^d$ where $d =4$), colored by their position +# in the 1st dimension in the original space. From 9a7a2ae4caed9c872599c77ce12616bf2c97e36b Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 11:51:36 +0200 Subject: [PATCH 25/61] more docs cleaning --- README.md | 30 ++++++------------------------ docs/make.jl | 2 +- docs/src/tutorials/01-basic.jl | 2 ++ 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 4f69a316..8a5e8b8d 100644 --- a/README.md +++ b/README.md @@ -14,37 +14,23 @@ If you use GigaSOM.jl and want to refer to it in your work, use the following ci # How to get started -## Prerequisites and requirements - -- **Operating system**: Use Linux (Debian, Ubuntu or centOS), MacOS, or Windows 10 as your operating system. GigaSOM has been tested on these systems. -- **Julia language**: In order to use GigaSOM, you need to install Julia 1.0 or higher. You can find the download and installation instructions for Julia [here](https://julialang.org/downloads/). -- **Hardware requirements**: GigaSOM runs on any hardware that can run Julia, and can easily use resources from multiple computers interconnected by network. For processing large datasets, you require to ensure that the total amount of available RAM on all involved computers is larger than the data size. - -:bulb: If you are new to Julia, it is adviseable to [familiarize youself with -the environment -first](https://docs.julialang.org/en/v1/manual/getting-started/). Use the full -Julia [documentation](https://docs.julialang.org) to solve various possible -language-related problems, and the [Julia package manager -docs](https://julialang.github.io/Pkg.jl/v1/getting-started/) to solve -installation-related difficulties. +As a major prerequisite, you will need to be able to run [Julia](https://julialang.org/). The environment is quite similar to other languages (R, Python, Matlab) and it is usually quite easy to start by following the [official user guide](). ## Installation -Using the Julia package manager to install GigaSOM is easy -- after starting Julia, type: +To install Julia, you can [download it from the official website](https://julialang.org/downloads/) or use your system's package manager to install one. Aim for using Julia version at least 1.6. +To install GigaSOM.jl, start Julia and type: ```julia import Pkg; Pkg.add("GigaSOM"); ``` -> All these commands should be run from Julia at the `julia>` prompt. - -Then you can load the GigaSOM package and start using it: - +After the installation finishes, you should be able to load GigaSOM package using: ```julia using GigaSOM ``` -The first loading of the GigaSOM package may take several minutes to complete due to precompilation of the sources, especially on a fresh Julia install. +The first loading of the GigaSOM package may take some time (a few minutes) to complete due to precompilation of the sources, especially if your Julia installation is new. ### Test the installation @@ -62,7 +48,7 @@ global_logger(ConsoleLogger(stderr, Logging.Debug)) ## How to use GigaSOM -A comprehensive documentation is [available online](https://lcsb-biocore.github.io/GigaSOM.jl/); several [introductory tutorials](https://lcsb-biocore.github.io/GigaSOM.jl/latest/tutorials/basicUsage/) of increasing complexity are also included. +A comprehensive documentation is [available online](https://lcsb-biocore.github.io/GigaSOM.jl/); several [introductory tutorials](https://lcsb-biocore.github.io/GigaSOM.jl/latest/tutorials/) of increasing complexity are also included. A very basic dataset (Levine13 from [FR-FCM-ZZPH](https://flowrepository.org/id/FR-FCM-ZZPH)) can be loaded, clustered and visualized as such: @@ -98,7 +84,3 @@ savePNG("Levine13-CD4.png", The output may look like this (blue is negative expresison, red is positive): ![Levine13 embedding with CD4 highlighted](docs/src/assets/Levine13-CD4.png "Levine13/CD4") - -## Feedback, issues, questions - -Please follow the [contributing guide](.github/CONTRIBUTING.md) when you have questions, want to raise issues, or just want to leave us some feedback! diff --git a/docs/make.jl b/docs/make.jl index e36c92d5..80970a86 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -31,7 +31,7 @@ withenv("COLUMNS" => 150) do "Home" => "index.md", "Background" => "background.md", "Tutorials" => example_mds, - "Tutorial (old)" => [ + "Tutorial (old)" => [ # TODO remove "Introduction" => "tutorials-old/basicUsage.md", "Cytometry data" => "tutorials-old/processingFCSData.md", "Advanced distributed processing" => "tutorials-old/distributedProcessing.md", diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index cfb9277d..d4cc3254 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -54,6 +54,8 @@ using GigaSOM # code generates a 4D hypercube of "size" 10 with gaussian clusters at the # cube's vertices: +import Random #src +Random.seed!(12345) #src d = randn(10000,4) .+ rand(0:1, 10000, 4).*10 # The SOM (of size 20×20) is created and trained as such: From 64c8b4439095674349c8c096a73c3f83c345221b Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 11:52:48 +0200 Subject: [PATCH 26/61] no more test --- docs/src/tutorials/test.jl | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 docs/src/tutorials/test.jl diff --git a/docs/src/tutorials/test.jl b/docs/src/tutorials/test.jl deleted file mode 100644 index 20c64d0e..00000000 --- a/docs/src/tutorials/test.jl +++ /dev/null @@ -1,4 +0,0 @@ - -# Basic tutorial - -println("test") From f2422c324bfa682839c9ab0c5ffc541bb5d24426 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 11:53:26 +0200 Subject: [PATCH 27/61] fixen --- docs/Project.toml | 1 + docs/src/tutorials/01-basic.jl | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 82806167..67a97bf3 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,4 +1,5 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" +GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index d4cc3254..73d686ef 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -88,12 +88,12 @@ e = embed(som, d) # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Gadfly`: -#TODO -#Pkg.add("Gadfly") -#Pkg.add("Cairo") -#using Gadfly -#import Cairo -#draw(PNG("test.png",20cm,20cm), plot(x=e[:,1], y=e[:,2], color=d[:,1])) +# TODO +# Pkg.add("Gadfly") +# Pkg.add("Cairo") +# using Gadfly +# import Cairo +# draw(PNG("test.png",20cm,20cm), plot(x=e[:,1], y=e[:,2], color=d[:,1])) # The output shows all 16 ($16 = 2^d$ where $d =4$), colored by their position # in the 1st dimension in the original space. From 4ea666ba0018dc0534bb63ddc0affc663e839277 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 12:10:37 +0200 Subject: [PATCH 28/61] make the docs render --- docs/make.jl | 10 +- docs/src/tutorials-old/basicUsage.md | 152 --------------------------- docs/src/tutorials.md | 7 ++ docs/src/tutorials/01-basic.jl | 6 +- 4 files changed, 16 insertions(+), 159 deletions(-) delete mode 100644 docs/src/tutorials-old/basicUsage.md create mode 100644 docs/src/tutorials.md diff --git a/docs/make.jl b/docs/make.jl index 80970a86..8d89525b 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -15,7 +15,7 @@ for example in examples ) end -example_mds = first.(splitext.(basename.(examples))) .* ".md" +tutorial_mds = first.(splitext.(basename.(examples))) .* ".md" withenv("COLUMNS" => 150) do makedocs( @@ -26,19 +26,21 @@ withenv("COLUMNS" => 150) do assets = ["assets/gigasomlogotransp.ico"], ), sitename = "GigaSOM.jl", - linkcheck = false, pages = [ "Home" => "index.md", "Background" => "background.md", - "Tutorials" => example_mds, + "Tutorials" => [ + "Contents" => "tutorials.md" + tutorial_mds + ], "Tutorial (old)" => [ # TODO remove - "Introduction" => "tutorials-old/basicUsage.md", "Cytometry data" => "tutorials-old/processingFCSData.md", "Advanced distributed processing" => "tutorials-old/distributedProcessing.md", "Conclusion" => "tutorials-old/whereToGoNext.md", ], "Reference" => "reference.md", ], + warnonly = [:linkcheck, :cross_references, :missing_docs], #TODO fix ) end diff --git a/docs/src/tutorials-old/basicUsage.md b/docs/src/tutorials-old/basicUsage.md deleted file mode 100644 index d3bca1d9..00000000 --- a/docs/src/tutorials-old/basicUsage.md +++ /dev/null @@ -1,152 +0,0 @@ - -# Tutorial 1: Intro & basic usage - -For the installation of Julia or GigaSOM.jl please refer to the installation -instructions. - -## High-level overview - -GigaSOM provides functions that allow straightforward loading of FCS files into -matrices, preparing these matrices for analysis, and running SOM and related -function on the data. - -The main functions, listed by category: - -- [`load_fcs`](@ref) and [`load_fcs_distributed`](@ref) for loading the data -- [`fcs_column_metadata`](@ref) and [`getMarkerNames`](@ref) for accessing the - information about data columns stored in FCS files -- [`dselect`](@ref), [`dtransform_asinh`](@ref), [`dscale`](@ref) and similar - functions for transforming, scaling and preparing the data -- [`init`](@ref) for initializing the self-organizing map, - [`train`](@ref) for running the SOM training, [`assign`](@ref) - for classification using the trained SOM, and [`embed`](@ref) for - dimensionality reduction to 2D - -Multiprocessing is done using the `Distributed` package -- if you add -more "workers" using the `addprocs` function, GigaSOM will -automatically react to that situation, split the data among the processes and -run parallel versions of all algorithms. - -### Horizontal scaling - -While all functions work well on simple data matrices, the main aim of GigaSOM -is to let the users enjoy the cluster-computing resources. All functions also -work on data that are "scattered" among workers (i.e. each worker only holds a -portion of the data loaded in the memory). This dataset description is stored -in [`Dinfo`](@ref) structure. Most of the functions above in fact -accept the `Dinfo` as argument, and often return another -`Dinfo` that describes the scattered result. - -Most importantly, using `Dinfo` **prevents memory exhaustion at the -master node**, which is a critical feature required to handle huge datasets. - -You can always collect the scattered data back into a matrix (if it fits to -your RAM) with `gather_array`, and utilize many other functions to manipulate -it, including e.g. `dmapreduce` for easily running parallel computations, or -`dstore` for saving and restoring the dataset paralelly. - -## Minimal working example - -First, load GigaSOM: - -```julia -using GigaSOM -``` - -We will create a bit of randomly generated data for this purpose. This code -generates a 4D hypercube of size 10 with gaussian clusters at vertices: - -```julia -d = randn(10000,4) .+ rand(0:1, 10000, 4).*10 -``` - -The SOM (of size 20x20) is created and trained as such: - -```julia -som = init(d, 20, 20) -som = train(som, d) -``` - -(Note that SOM initialization is randomized; if you want to get the same -results everytime, use e.g. `Random.seed!(1)`.) - -You can now see the SOM codebook (your numbers will vary): - -```julia -som.codes -``` - -``` -400×4 Array{Float64,2}: - -0.361681 -0.57191 0.140438 9.99224 - -1.111 1.60277 -0.209706 9.96805 - -1.23305 7.58148 -0.445886 9.76316 - -0.285692 9.80184 -1.12107 9.85507 - -0.197007 10.8793 -0.649294 9.89448 - -0.334737 11.0858 0.213889 9.93479 - 0.00282155 11.0725 0.718114 10.2714 - -0.333398 10.1315 1.14412 10.564 - -0.0124202 1.48128 8.35741 10.72 - -0.0084074 0.0150858 9.91007 11.5361 - ⋮ -``` - -This information can be used to categorize the dataset into clusters: - -```julia -assign(som, d) -``` - -In the result, `index` is a cluster ID for the original datapoint from `d` at -the same row. -``` -10000×1 DataFrames.DataFrame -│ Row │ index │ -│ │ Int64 │ -├───────┼───────┤ -│ 1 │ 381 │ -│ 2 │ 178 │ -│ 3 │ 348 │ -│ 4 │ 379 │ -│ 5 │ 80 │ -│ 6 │ 146 │ -│ 7 │ 57 │ -⋮ -``` - -(As in the previous case, your numbers may differ.) - -Finally, you can use EmbedSOM dimensionality reduction to convert all -multidimensional points to 2D; which can eventually be used to create a -good-looking 2D scatterplot. -```julia -e = embed(som,d) -``` - -``` -10000×2 Array{Float64,2}: - 1.41575 18.5282 - 17.4483 7.4137 - 6.88243 17.5722 - 17.654 18.0348 - 17.594 3.15645 - 5.27181 8.61096 - 15.9708 2.8124 - 6.19637 9.05302 - 1.49358 7.19198 - 16.596 7.75608 - ⋮ -``` - -The 2D coordinates may be plotted by any standard plotting library. In the -following example we show how to do that with `Gadfly`: -```julia -Pkg.add("Gadfly") -Pkg.add("Cairo") -using Gadfly -import Cairo -draw(PNG("test.png",20cm,20cm), plot(x=e[:,1], y=e[:,2], color=d[:,1])) -``` - -In the resulting picture, you should be able to see all 16 gaussian clusters -colored by the first dimension in the original space. diff --git a/docs/src/tutorials.md b/docs/src/tutorials.md new file mode 100644 index 00000000..3341d86d --- /dev/null +++ b/docs/src/tutorials.md @@ -0,0 +1,7 @@ + +# Tutorials + +```@contents +Pages = filter(x -> endswith(x, ".md"), readdir("tutorials", join=true)) +Depth = 2 +``` diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index 73d686ef..a36c31a0 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -56,7 +56,7 @@ using GigaSOM import Random #src Random.seed!(12345) #src -d = randn(10000,4) .+ rand(0:1, 10000, 4).*10 +d = randn(10000,4) .+ rand(0:1, 10000, 4).*10; # The SOM (of size 20×20) is created and trained as such: @@ -75,7 +75,7 @@ som.codes # This information can be used to categorize the dataset into clusters: -assign(som, d) +assign(som, d); # In the result, `index` is a cluster ID for the original datapoint from `d` at # the same row. (As in the previous case, your numbers may differ.) @@ -83,7 +83,7 @@ assign(som, d) # Finally, you can use EmbedSOM dimensionality reduction to convert all # multidimensional points to 2D; which can eventually be used to create a # good-looking 2D scatterplot. -e = embed(som, d) +e = embed(som, d); # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Gadfly`: From 14075bc3da84dd4302a95c1846acff2a6d1ad515 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 12:19:02 +0200 Subject: [PATCH 29/61] avoid dataframes by default --- src/som.jl | 8 ++++---- test/testBatch.jl | 2 +- test/testParallel.jl | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/som.jl b/src/som.jl index 915a539c..3bbfd17f 100644 --- a/src/som.jl +++ b/src/som.jl @@ -243,9 +243,9 @@ end """ $(TYPEDSIGNATURES) -Overload of `assign` for simple DataFrames and matrices. This slices the -data using `DistributedArrays`, sends them the workers, and runs normal -`assign`. Data is `unscatter`d after the computation. +Overload of `assign` for simple data such as DataFrames and matrices. This +slices the data using `DistributedArrays`, sends them the workers, and runs +normal `assign`. Data is `unscatter`d after the computation. """ function assign(som::SOM, data; knnTreeFun = BruteTree, metric = Euclidean()) @@ -261,7 +261,7 @@ function assign(som::SOM, data; knnTreeFun = BruteTree, metric = Euclidean()) res = gather_array(rInfo) unscatter(dInfo) unscatter(rInfo) - return DataFrame(index = res) + return res end export assign diff --git a/test/testBatch.jl b/test/testBatch.jl index 12b0b99a..ad556bba 100644 --- a/test/testBatch.jl +++ b/test/testBatch.jl @@ -30,7 +30,7 @@ rename!(dfCodes, Symbol.(antigens)) dfEmbed = DataFrame(e, :auto) CSV.write(genDataPath * "/batchDfCodes.csv", dfCodes) - CSV.write(genDataPath * "/batchWinners.csv", winners) + CSV.write(genDataPath * "/batchWinners.csv", DataFrame(index = winners)) CSV.write(genDataPath * "/batchEmbedded.csv", dfEmbed) #load the ref data diff --git a/test/testParallel.jl b/test/testParallel.jl index 773f86bc..9188dc0d 100644 --- a/test/testParallel.jl +++ b/test/testParallel.jl @@ -27,7 +27,7 @@ rename!(dfCodes, Symbol.(antigens)) dfEmbed = DataFrame(e, :auto) CSV.write(genDataPath * "/parallelDfCodes.csv", dfCodes) - CSV.write(genDataPath * "/parallelWinners.csv", winners) + CSV.write(genDataPath * "/parallelWinners.csv", DataFrame(index = winners)) CSV.write(genDataPath * "/parallelEmbedded.csv", dfEmbed) # load the ref data From acd7b28d2c46532ba88367a0f11b99288b3e4030 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 12:44:43 +0200 Subject: [PATCH 30/61] more cleaning --- Project.toml | 4 ++- docs/src/tutorials/01-basic.jl | 4 +-- test/aqua.jl | 6 +++++ test/runtests.jl | 49 +++++++++++++++------------------- 4 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 test/aqua.jl diff --git a/Project.toml b/Project.toml index affbfd5e..f3b41d43 100644 --- a/Project.toml +++ b/Project.toml @@ -18,6 +18,7 @@ Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" [compat] +Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" @@ -33,6 +34,7 @@ XLSX = "0.8" julia = "1" [extras] +Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" @@ -40,4 +42,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] +test = ["Aqua", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index a36c31a0..8ce84949 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -75,7 +75,7 @@ som.codes # This information can be used to categorize the dataset into clusters: -assign(som, d); +assign(som, d) # In the result, `index` is a cluster ID for the original datapoint from `d` at # the same row. (As in the previous case, your numbers may differ.) @@ -83,7 +83,7 @@ assign(som, d); # Finally, you can use EmbedSOM dimensionality reduction to convert all # multidimensional points to 2D; which can eventually be used to create a # good-looking 2D scatterplot. -e = embed(som, d); +e = embed(som, d) # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Gadfly`: diff --git a/test/aqua.jl b/test/aqua.jl new file mode 100644 index 00000000..02a1519c --- /dev/null +++ b/test/aqua.jl @@ -0,0 +1,6 @@ + +@testset "Aqua.jl" begin + Aqua.test_all( + GigaSOM; + ) +end diff --git a/test/runtests.jl b/test/runtests.jl index 8d8414d5..bd7596ee 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,36 +1,29 @@ -using GigaSOM, DataFrames, XLSX, CSV, Test, Random, Distributed, DistributedData -using FileIO, DataFrames, Distances -using JSON, SHA -import LinearAlgebra -owd = pwd() +using Test +using Aqua +import GigaSOM -""" -Check if the `pwd()` is the `/test` directory, and if not it changes to it. -""" -function checkDir() - files = readdir() - if !in("runtests.jl", files) - cd(dirname(dirname(pathof(GigaSOM)))) - end +# helper functions for running tests en masse +print_timing(fn, t) = @info "$(fn) done in $(round(t; digits = 2))s" + +function run_test_file(path...) + fn = joinpath(path...) + t = @elapsed include(fn) + print_timing(fn, t) end -checkDir() +function run_doc_examples() + for ex in filter(endswith(".jl"), readdir("../docs/src/tutorials", join = true)) + @testset "docs/$(basename(ex))" begin + run_test_file(ex) + end + end +end @testset "GigaSOM test suite" begin - include("testDataOps.jl") - include("testTrainutils.jl") - include("testSplitting.jl") - include("testInput.jl") - - #this loads the PBMC dataset required for the batch/parallel tests - include("testLoadPBMC8.jl") - include("testBatch.jl") - include("testParallel.jl") + @testset "Documentation tests" begin + run_doc_examples() + end - #misc tests that require some of the above data too - include("testInputCSV.jl") - include("testFileSplitting.jl") + run_test_file("aqua.jl") end - -cd(owd) From eaa112f633d0da476dc6d8ff1442c34ed2ba61a7 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 12:58:49 +0200 Subject: [PATCH 31/61] translate tutorial 2 --- docs/src/tutorials-old/processingFCSData.md | 272 -------------------- docs/src/tutorials/02-flow-data.jl | 201 +++++++++++++++ 2 files changed, 201 insertions(+), 272 deletions(-) delete mode 100644 docs/src/tutorials-old/processingFCSData.md create mode 100644 docs/src/tutorials/02-flow-data.jl diff --git a/docs/src/tutorials-old/processingFCSData.md b/docs/src/tutorials-old/processingFCSData.md deleted file mode 100644 index a3bdb2cb..00000000 --- a/docs/src/tutorials-old/processingFCSData.md +++ /dev/null @@ -1,272 +0,0 @@ - -# Tutorial 2: Working with cytometry data - -You can load any FCS file using [`load_fcs`](@ref) function. For example, the -Levine dataset ([obtainable here](https://flowrepository.org/id/FR-FCM-ZZPH)) -may be loaded as such: - -``` -params, data = load_fcs("Levine_13dim.fcs") -``` - -`params` will now contain the list of FCS parameters; you can parse a lot of -interesting information from it using the [`fcs_column_metadata`](@ref) function: - -```julia -fcs_column_metadata(params) -``` - -``` -14×8 DataFrames.DataFrame. Omitted printing of 3 columns -│ Row │ E │ S │ N │ RMIN │ R │ -│ │ String │ String │ String │ String │ String │ -├─────┼────────┼────────┼────────┼───────────────────┼────────┤ -│ 1 │ 0,0 │ │ CD45 │ -2.03601189282714 │ 1024 │ -│ 2 │ 0,0 │ │ CD45RA │ -2.99700270621007 │ 1024 │ -│ 3 │ 0,0 │ │ CD19 │ -3.05850183816765 │ 1024 │ -│ 4 │ 0,0 │ │ CD11b │ -2.99956408593931 │ 1024 │ -│ 5 │ 0,0 │ │ CD4 │ -2.22860674361335 │ 1024 │ -│ 6 │ 0,0 │ │ CD8 │ -3.29174106765763 │ 1024 │ -│ 7 │ 0,0 │ │ CD34 │ -2.74278770893026 │ 1024 │ -│ 8 │ 0,0 │ │ CD20 │ -3.40866348184011 │ 1024 │ -│ 9 │ 0,0 │ │ CD33 │ -2.31371406643428 │ 1024 │ -│ 10 │ 0,0 │ │ CD123 │ -3.02624638359366 │ 1024 │ -│ 11 │ 0,0 │ │ CD38 │ -3.14752313833461 │ 1024 │ -│ 12 │ 0,0 │ │ CD90 │ -2.55305031846157 │ 1024 │ -│ 13 │ 0,0 │ │ CD3 │ -3.52459385416266 │ 1024 │ -│ 14 │ 0,0 │ │ label │ 1 │ 1024 │ -``` - -`data` is a matrix with cell expressions, one cell per row, one marker per -column. If you want to run SOM analysis on it, you can cluster and visualize it -just as in the previous tutorial, with one exception- we start with cutting off -the `label` column that contains `NaN` values: - -``` - -data = data[:,1:13] -som = init(data, 16, 16) -som = train(som, data) -clusters = assign(som, data) -e = embed(som, data) - -# ... save/plot results, etc... -``` - -## Work with distributed data - -Usual experiments produce multiple FCS files, and distributed or parallel -processing is very helpful in crunching through all the data. - -To load multiple FCS files, use [`load_fcs_distributed`](@ref). This function works well -in the "usual" single-process environment, but additionally it is designed to -handle situations when the data is too big to fit into memory, and attempts to -split them into available distributed workers workers. - -For the purpose of data distribution, you need to identify each dataset by an -unique **dataset name** that will be used for identifying your loaded data in -the cluster environment. The dataset name is a simple Julia symbols; basically -a variable name that is prefixed with a `:` colon. - -For example, we can load the Levine13 dataset as such: - -```julia -datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) -``` - -Expectably, if you have more files, just write their names into the array and -the function will handle the rest. - -The result `datainfo` carries informaton about your selected dataset name and -its distribution among the cluster. It can be used just as the "data" parameter -in all SOM-related functions again; e.g. as `train(som, datainfo)`. - -The following example exploits the possibility to actually split the data, and -processes the Levine dataset parallelly on 2 workers: - -```julia -using Distributed -addprocs(2) # add any number of CPUs/tasks/workers you have available -@everywhere using GigaSOM # load GigaSOM also on the workers - -datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) # add more files as needed - -dselect(datainfo, Vector(1:13)) # select columns that contain expressions (column 14 contains labels) -som = init(datainfo, 20, 20) -som = train(som, datainfo) -``` - -To prevent memory overload of the "master" computation node, the results of all -per-cell operations are also stored in distributed datainfo objects. In this -case, the following code does the embedding, but leaves the resulting data -safely scattered among the cluster: -```julia -e = embed(som, datainfo) -``` - -If you are sure you have enough RAM, you can collect the data to the master -node. (In case of the relatively small Levine13 dataset, you very probably have -the required 2.5MB of RAM, but there are many larger datasets.) -```julia -e = gather_array(e) -``` - -``` -167044×2 Array{Float64,2}: - 16.8251 11.5002 - 18.2608 12.884 - 12.0103 5.18401 - 18.381 12.3436 - 18.357 11.6622 - 14.8936 12.0897 - 17.6441 12.3652 - 17.8752 12.7206 - 17.301 11.0767 - 14.2055 12.2227 - ⋮ -``` - -## Working with larger datasets - -In this example we will use a subset of the Cytometry data [by Bodenmiller et -al.](https://doi.org/10.1038/nbt.2317). This data-set contains samples -from peripheral blood mononuclear cells (PBMCs) in unstimulated and stimulated -conditions for 8 healthy donors. - -10 cell surface markers (lineage markers) are used to identify different cell -populations. The dataset is described in two files: - -- `PBMC8_panel.xlsx` (with antigen names categorized as lineage markers and functional markers) -- `PBMC8_metadata.xlsx` (file names, sample IDs, condition IDs and patient IDs) - -### Download and prepare the dataset - -The example data can be downloaded from [imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/) - -You can fetch the files directly from within Julia: - -```julia -# fetch the required data for testing and download the zip archive and unzip it -dataFiles = ["PBMC8_metadata.xlsx", "PBMC8_panel.xlsx", "PBMC8_fcs_files.zip"] -for f in dataFiles - if !isfile(f) - download("http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/"*f, f) - if occursin(".zip", f) - run(`unzip PBMC8_fcs_files.zip`) - end - end -end -``` - -The metadata is present in external files; we read it into a `DataFrame` and -extract information about FCS data columns from there. First, we read the -actual content using the XLSX package: - -```julia -using XLSX -md = GigaSOM.DataFrame(readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes=true)...) -panel = GigaSOM.DataFrame(readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes=true)...) -``` - -After that, we can get the parameter structure from the first FCS files: -```julia -_, fcsParams = read_fcs_header(md[1, :file_name]) -``` - -Continue with extracting marker names using the prepared functions: -```julia -_, fcsAntigens = getMarkerNames(fcs_column_metadata(fcsParams)) -``` - -Now, see which antigens we want to use (assume we want only the lineage markers): -```julia -antigens = panel[panel[:,:Lineage].==1, :Antigen] -``` - -Finally, it is often useful to make the names a bit more Julia-friendly and predictable: -```julia -cleanNames!(antigens) -cleanNames!(fcsAntigens) -``` - -### Load and prepare the data - -Now we have the vector of `fcsAntigens` that the FCS files store, and list of -`antigens` that we want to analyze. We continue by loading the data, reducing -it to the desired antigens and transforming it a bit: - -```julia -di = load_fcs_distributed(:pbmc8, md[:,:file_name]) -``` - -(If data distribution and parallelization is required, you must add parallel -workers using `addprocs` **before** this step.) - -Now that the data is loaded, let's prepare them a bit by reducing to actual -interesting columns, transformation and scaling: - -```julia -# select only the columns that correspond to the lineage antigens we have prepared before -dselect(di, fcsAntigens, antigens) - -cols = Vector(1:length(antigens)) # shortcut for "all rows" - -# perform asinh transformation on all data in the dataset, '5' is the cofactor for the transformation -dtransform_asinh(di, cols, 5) - -# normalize all dataset columns to mean=0 sdev=1 -dscale(di, cols) -``` - -### Create a Self Organizing MAP (SOM) - -With the data prepared, running the SOM algorithm is straightforward: - -```julia -# randomly initialize the SOM -som = init(di, 16, 16) - -# train the SOM for 20 epochs (10 is default, but nothing will happen if the -# epochs are slightly overdone) -som = train(som, di, epochs = 20) -``` - -Finally, calculate the clustering: - -```julia -somClusters = assign(som, di) -``` - -### FlowSOM-style metaclustering - -One disadvantage of SOMs is that they output a large amount of small -clusters that are relatively hard to interpret manually. FlowSOM improved that -situation by running a "clustering on clusters" (metaclustering) that address -the problem. - -In this example, we reduce the original 256 small clusters from 16x16 SOM to -only 10 "metaclusters", using the standard hierarchical clustering: - -```julia -using Clustering -import Distances -metaClusters = - cutree(k=10, - hclust(linkage=:average, - GigaSOM.distMatrix(Distances.Euclidean())(som.codes))) -``` - -The `metaClusters` represent membership of the SOM codes in cluster; these can -be expanded to membership of all cells using [`assign`](@ref): - -```julia -mapping = gather_array(assign(som, di), free=true) -clusters = metaClusters[mapping] -``` - -`clusters` now contain an integer from `1` to `10` with a classification of -each cell in the dataset. - -(The argument `free=true` of `gather_array` automatically removes the -distributed data from workers after collecting, which saves their memory for -other datasets.) diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl new file mode 100644 index 00000000..7da21c14 --- /dev/null +++ b/docs/src/tutorials/02-flow-data.jl @@ -0,0 +1,201 @@ + +# # Tutorial 2: Working with cytometry data +# +# You can load any FCS file using [`load_fcs`](@ref) function. For example, the +# Levine dataset ([obtainable here](https://flowrepository.org/id/FR-FCM-ZZPH)) +# may be loaded as such: + +using GigaSOM +import Downloads: download + +params, data = load_fcs("Levine_13dim.fcs") + +# `params` will now contain the list of FCS parameters; you can parse a lot of +# interesting information from it using the [`fcs_column_metadata`](@ref) +# function: + +fcs_column_metadata(params) + +# `data` is a matrix with cell expressions, one cell per row, one marker per +# column. If you want to run SOM analysis on it, you can cluster and visualize +# it just as in the previous tutorial, with one exception- we start with +# cutting off the `label` column that contains `NaN` values: + +data = data[:,1:13] +som = init(data, 16, 16) +som = train(som, data) +clusters = assign(som, data) +e = embed(som, data) + +# ... etc, you can save the results and plot them as needed. +# +# ## Working with distributed data +# +# Usual experiments produce multiple FCS files, and distributed or parallel +# processing is very helpful in crunching through all the data. +# +# To load multiple FCS files, use [`load_fcs_distributed`](@ref). This function +# works well in the "usual" single-process environment, but additionally it is +# designed to handle situations when the data is too big to fit into memory, +# and attempts to split them into available distributed workers workers. +# +# For the purpose of data distribution, you need to identify each dataset by an +# unique **dataset name** that will be used for identifying your loaded data in +# the cluster environment. The dataset name is a simple Julia symbols; +# basically a variable name that is prefixed with a `:` colon. +# +# For example, we can load the Levine13 dataset as such: + +datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) + +# Expectably, if you have more files, just write their names into the array and +# the function will handle the rest. +# +# The result `datainfo` carries informaton about your selected dataset name and +# its distribution among the cluster. It can be used just as the "data" +# parameter in all SOM-related functions again; e.g. as `train(som, datainfo)`. +# +# The following example exploits the possibility to actually split the data, +# and processes the Levine dataset parallelly on 2 workers: + +using Distributed +addprocs(2) # add any number of CPUs/tasks/workers you have available +@everywhere using GigaSOM # load GigaSOM also on the workers + +datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) + +# We select only columns that contain expressions (in the dataset, column 14 +# contains labels): +dselect(datainfo, Vector(1:13)) + +# Training may continue as usual: +som = init(datainfo, 20, 20) +som = train(som, datainfo) + +# To prevent memory overload of the "master" computation node, the results of +# all per-cell operations are also stored in distributed "data info" objects. +# In this case, the following code does the embedding, but leaves the resulting +# data safely scattered among the cluster: +e = embed(som, datainfo) + +# If you are sure you have enough RAM, you can collect the data to the master +# node. (In case of the relatively small Levine13 dataset, you very probably +# have the required 2.5MB of RAM, but there are many larger datasets.) +e = gather_array(e) + +# ## Working with larger datasets +# +# In this example we will use a subset of the Cytometry data [by Bodenmiller et +# al.](https://doi.org/10.1038/nbt.2317). This dataset contains samples from +# peripheral blood mononuclear cells (PBMCs) in unstimulated and stimulated +# conditions for 8 healthy donors. +# +# 10 cell surface markers (lineage markers) are used to identify different cell +# populations. The dataset is described in two files: +# +# - `PBMC8_panel.xlsx` (with antigen names categorized as lineage markers and +# functional markers) +# - `PBMC8_metadata.xlsx` (file names, sample IDs, condition IDs and patient +# IDs) + +### Download and prepare the dataset + +# The example data can be downloaded from +# [imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/). +# +# You can fetch the files directly from within Julia: + +dataFiles = ["PBMC8_metadata.xlsx", "PBMC8_panel.xlsx", "PBMC8_fcs_files.zip"] +for f in dataFiles + if !isfile(f) + download("http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/"*f, f) + if occursin(".zip", f) + run(`unzip PBMC8_fcs_files.zip`) + end + end +end + +# The metadata is present in external files; we read it into a `DataFrame` and +# extract information about FCS data columns from there. First, we read the +# actual content using the XLSX package: + +using XLSX +md = GigaSOM.DataFrame(readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes=true)...) +panel = GigaSOM.DataFrame(readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes=true)...) + +# After that, we can get the parameter structure from the first FCS files: +_, fcsParams = read_fcs_header(md[1, :file_name]) + +# ...and continue with extracting marker names using the prepared functions: +_, fcsAntigens = fcs_header_marker_names(fcs_column_metadata(fcsParams)) + +# Now, TO see which antigens we want to use (assume we want only the lineage +# markers): +antigens = panel[panel[:,:Lineage].==1, :Antigen] + +# Finally, it is often useful to make the names a bit more Julia-friendly and +# predictable: +clean_names!(antigens) +clean_names!(fcsAntigens) + +# ### Load and prepare the data +# +# Now we have the vector of `fcsAntigens` that the FCS files store, and list of +# `antigens` that we want to analyze. We continue by loading the data, reducing +# it to the desired antigens and transforming it a bit: +di = load_fcs_distributed(:pbmc8, md[:,:file_name]) + +# (If data distribution and parallelization is required, you must add parallel +# workers using `addprocs` **before** this step.) + +# Now that the data is loaded, let's prepare them a bit by reducing to actual +# interesting columns, transformation and scaling. First, select only the +# columns that correspond to the lineage antigens we have prepared before: +dselect(di, fcsAntigens, antigens) +cols = Vector(eachindex(antigens)) # shortcut for "all rows" + +# Perform `asinh` transformation on all data in the dataset, '5' here is the +# cofactor for the transformation: +dtransform_asinh(di, cols, 5) + +# Normalize all dataset columns to zero mean and unit variance: +dscale(di, cols) + +# ### Train a self-organizing map (SOM) +# +# With the data prepared, running the SOM algorithm is straightforward: +som = init(di, 16, 16, seed = 12345) +som = train(som, di, epochs = 20) + +# Finally, we can calculate the clustering: +som_clusters = assign(som, di) + +# ### FlowSOM-style metaclustering +# +# One disadvantage of SOMs is that they output a large amount of small clusters +# that are relatively hard to interpret manually. FlowSOM improved that +# situation by running a "clustering on clusters" (metaclustering) that address +# the problem. +# +# In this example, we reduce the original 256 small clusters from 16x16 SOM to +# only 10 "metaclusters", using the standard hierarchical clustering: + +using Clustering +import Distances +metaClusters = + cutree(k=10, + hclust(linkage=:average, + GigaSOM.distance_matrix(Distances.Euclidean())(som.codes))) + +# The `metaClusters` represent membership of the SOM codes in cluster; these +# can be expanded to membership of all cells using [`assign`](@ref): + +mapping = gather_array(assign(som, di), free = true) +clusters = metaClusters[mapping] + +# `clusters` now contain integers from `1` to `10` that classify each cell in +# the dataset. +# +# (The argument `free=true` of `gather_array` automatically removes the +# distributed data from workers after collecting, which saves their memory for +# other datasets.) From d075c36baead09cde6e30393ce7a42081b68b8c6 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 12:59:09 +0200 Subject: [PATCH 32/61] format code --- docs/src/tutorials/01-basic.jl | 2 +- docs/src/tutorials/02-flow-data.jl | 18 +++++++++--------- test/aqua.jl | 4 +--- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index 8ce84949..4da2c3b4 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -56,7 +56,7 @@ using GigaSOM import Random #src Random.seed!(12345) #src -d = randn(10000,4) .+ rand(0:1, 10000, 4).*10; +d = randn(10000, 4) .+ rand(0:1, 10000, 4) .* 10; # The SOM (of size 20×20) is created and trained as such: diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 7da21c14..74d8fdcc 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -21,7 +21,7 @@ fcs_column_metadata(params) # it just as in the previous tutorial, with one exception- we start with # cutting off the `label` column that contains `NaN` values: -data = data[:,1:13] +data = data[:, 1:13] som = init(data, 16, 16) som = train(som, data) clusters = assign(som, data) @@ -120,8 +120,8 @@ end # actual content using the XLSX package: using XLSX -md = GigaSOM.DataFrame(readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes=true)...) -panel = GigaSOM.DataFrame(readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes=true)...) +md = GigaSOM.DataFrame(readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)...) +panel = GigaSOM.DataFrame(readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)...) # After that, we can get the parameter structure from the first FCS files: _, fcsParams = read_fcs_header(md[1, :file_name]) @@ -131,7 +131,7 @@ _, fcsAntigens = fcs_header_marker_names(fcs_column_metadata(fcsParams)) # Now, TO see which antigens we want to use (assume we want only the lineage # markers): -antigens = panel[panel[:,:Lineage].==1, :Antigen] +antigens = panel[panel[:, :Lineage] .== 1, :Antigen] # Finally, it is often useful to make the names a bit more Julia-friendly and # predictable: @@ -143,7 +143,7 @@ clean_names!(fcsAntigens) # Now we have the vector of `fcsAntigens` that the FCS files store, and list of # `antigens` that we want to analyze. We continue by loading the data, reducing # it to the desired antigens and transforming it a bit: -di = load_fcs_distributed(:pbmc8, md[:,:file_name]) +di = load_fcs_distributed(:pbmc8, md[:, :file_name]) # (If data distribution and parallelization is required, you must add parallel # workers using `addprocs` **before** this step.) @@ -182,10 +182,10 @@ som_clusters = assign(som, di) using Clustering import Distances -metaClusters = - cutree(k=10, - hclust(linkage=:average, - GigaSOM.distance_matrix(Distances.Euclidean())(som.codes))) +metaClusters = cutree( + k = 10, + hclust(linkage = :average, GigaSOM.distance_matrix(Distances.Euclidean())(som.codes)), +) # The `metaClusters` represent membership of the SOM codes in cluster; these # can be expanded to membership of all cells using [`assign`](@ref): diff --git a/test/aqua.jl b/test/aqua.jl index 02a1519c..89c53116 100644 --- a/test/aqua.jl +++ b/test/aqua.jl @@ -1,6 +1,4 @@ @testset "Aqua.jl" begin - Aqua.test_all( - GigaSOM; - ) + Aqua.test_all(GigaSOM;) end From 07fe08b2e34f0e060c74621cfb3deff4e264ac96 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 25 Aug 2025 16:04:35 +0200 Subject: [PATCH 33/61] convert tutorial 3 --- docs/make.jl | 6 +- .../whereToGoNext.md => nextsteps.md} | 2 +- .../tutorials-old/distributedProcessing.md | 185 ------------------ docs/src/tutorials/03-distributed-data.jl | 122 ++++++++++++ 4 files changed, 124 insertions(+), 191 deletions(-) rename docs/src/{tutorials-old/whereToGoNext.md => nextsteps.md} (94%) delete mode 100644 docs/src/tutorials-old/distributedProcessing.md create mode 100644 docs/src/tutorials/03-distributed-data.jl diff --git a/docs/make.jl b/docs/make.jl index 8d89525b..767db13c 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -33,11 +33,7 @@ withenv("COLUMNS" => 150) do "Contents" => "tutorials.md" tutorial_mds ], - "Tutorial (old)" => [ # TODO remove - "Cytometry data" => "tutorials-old/processingFCSData.md", - "Advanced distributed processing" => "tutorials-old/distributedProcessing.md", - "Conclusion" => "tutorials-old/whereToGoNext.md", - ], + "Further reading" => "nextsteps.md", "Reference" => "reference.md", ], warnonly = [:linkcheck, :cross_references, :missing_docs], #TODO fix diff --git a/docs/src/tutorials-old/whereToGoNext.md b/docs/src/nextsteps.md similarity index 94% rename from docs/src/tutorials-old/whereToGoNext.md rename to docs/src/nextsteps.md index ce1f0542..d2caeb20 100644 --- a/docs/src/tutorials-old/whereToGoNext.md +++ b/docs/src/nextsteps.md @@ -20,4 +20,4 @@ the most frequently used ones: neighbors (`k`) and smoothing of the neighborhood (`smooth`) of [`embed`](@ref). - For plotting of really huge data, you may want to try - [GigaScatter](https://github.com/LCSB-BioCore/GigaScatter.jl). + [GigaScatter.jl](https://github.com/LCSB-BioCore/GigaScatter.jl). diff --git a/docs/src/tutorials-old/distributedProcessing.md b/docs/src/tutorials-old/distributedProcessing.md deleted file mode 100644 index 72323675..00000000 --- a/docs/src/tutorials-old/distributedProcessing.md +++ /dev/null @@ -1,185 +0,0 @@ - -# Tutorial 3: Distributed data processing and statistics - -If you can get the data on a single machine, computation of various statistics -can be performed using standard Julia functions. With large datasets that do -not fit on a computer, things get more complicated. Luckily, many statistics -and algorithms possess parallel, map-reduce-style implementations that can be -used to address this problem. - -For example, the [`dstat`](@ref) function computes sample statistics without -fetching all data to a single node. You can use it in a way similar to -aforementioned `dselect` and `dtransform_asinh`. The following code extracts -means and standard deviations from the first 3 columns of a dataset distributed -as `di`: -```julia -using DistributedData - -dstat(di, [1,2,3]) -``` - -## Manual work with the DistributedData.jl package - -We will first show how to use the general framework to compute per-cluster -statistics. DistributedData.jl exports the [`dmapreduce`](@ref) function that -can be used as a very effective basic building block for running such -computations. For example, you can efficiently compute a distributed mean of -all your data as such: -```julia -dmapreduce(di, sum, +) / dmapreduce(di, length, +) -``` - -The parameters of `dmapreduce` are, in order: - -- `di`, the dataset -- `sum` or `length`, an unary "map" function -- during the computation, each - piece of distributed data is first _paralelly_ processed by this function -- `+`, a binary "reduction" or "folding" function -- the pieces of information - processed by the map function are successively joined in pairs using this - function, until there is only a single result left. This final result is also - what `dmapreduce` returns. - -Above example thus reads: Sum all data on all workers, add up the intermediate -results, and divide the final number to the sum of all lengths of data on the -workers. - -Column-wise mean (as produced by `dstat`) is slightly more useful; we only need -to split the computation on columns: - -```julia -dmapreduce(di, d -> mapslices(sum, d, dims=1), +) ./ dmapreduce(di, x->size(x,1), +) -``` - -Finally, for distributed computation of per-cluster mean, the clustering -information needs to be distributed as well (Fortunately, that is easy, because -the distributed `assign` does exactly that). - -First, compute the clustering: -```julia -mapping = assign(som, di) -dtransform(mapping, m -> metaClusters[m]) -``` - -Now, the distributed computation is run on 2 scattered datasets. We employ a -helper function `mapbuckets` which provides bucket-wise execution of a -function, in a way very similar to `mapslices`. (In the example, we actually -use `catmapbuckets` that concatenates the result into a nice array.) The -following code produces a matrix of tuples `(sum, count)`, for separate -clusters (in rows) and data columns (in columns): - -```julia -sumscounts = dmapreduce([di, mapping], - (d, mapping) -> catmapbuckets( - (_,clData) -> (sum(clData), length(clData)), - d, 10, mapping), - (a,b) -> (((as,al),(bs,bl)) -> ((as+bs), (al+bl))).(a,b)) -``` - -``` -10×4 Array{Tuple{Float64,Int64},2}: - (5949.71, 1228) (-21.9789, 1228) (12231.3, 1228) (12303.1, 1228) - (6379.98, 1246) (12464.3, 1246) (12427.9, 1246) (12479.8, 1246) - (6513.41, 1294) (12968.8, 1294) (12960.7, 1294) (-28.1922, 1294) - (6312.37, 1236) (-26.7392, 1236) (6.74384, 1236) (12401.7, 1236) - (6395.73, 1285) (12867.7, 1285) (-52.653, 1285) (-26.9795, 1285) - (6229.72, 622) (10.7578, 622) (6200.1, 622) (0.882128, 622) - (6141.97, 612) (6078.56, 612) (45.9878, 612) (6079.3, 612) - (51.3709, 616) (23.4306, 616) (6117.53, 616) (1.15342, 616) - (6177.16, 1207) (-50.4624, 1207) (48.8023, 1207) (-5.549, 1207) - (8.56597, 654) (6536.1, 654) (-29.2208, 654) (6539.94, 654) -``` - -With a bit of Julia, this can be aggregated to actual per-cluster means: -```julia -clusterMeans = [ sum/count for (sum,count) in sumcounts ] -``` - -``` -10×4 Array{Float64,2}: - 4.84504 -0.0178982 9.96031 10.0188 - 5.12037 10.0034 9.97428 10.0159 - 5.03354 10.0223 10.016 -0.0217869 - 5.10709 -0.0216336 0.00545618 10.0337 - 4.97722 10.0138 -0.0409751 -0.0209958 - 10.0156 0.0172955 9.968 0.00141821 - 10.0359 9.93229 0.0751434 9.9335 - 0.0833944 0.0380366 9.93105 0.00187243 - 5.11778 -0.0418081 0.0404327 -0.00459735 - 0.0130978 9.99403 -0.0446802 9.99991 -``` - -Since we used the data from the hypercube dataset from the beginning of the -tutorial, you should be able to recognize several clusters that perfectly match -the hypercube vertices (although not all, because `k=10` is not enough to -capture all of the actual 16 existing clusters) - -Finally, we can remove the temporary data from workers to create free memory for other analyses: -```julia -unscatter(mapping) -``` - -## Convenience statistical functions - -Notably, several of the most used statistical functions are available in -DistributedData.jl in a form that can cope with distributed data. - -For example, you can run a distributed median computation as such: -```julia -dmedian(di, [1,2,3,4]) -``` - -In the hypercube dataset, the medians are slightly off-center because there is -a lot of empty space between the clusters: -``` -3-element Array{Float64,1}: - 6.947097488861494 - 7.934405685940568 - 7.069149844215707 - 2.558892109203585 -``` - -`dstat` function has a bucketed variant that can split the statistics among -different clusters. This computes the per-cluster standard deviations of the -dataset: - -```julia -dstat_buckets(di, 10, mapping, [1,2,3,4])[2] -``` - -In the result, we can count 4 "nice" clusters, and 6 clusters that span 2 of -the original clusters, totally giving 16. (Hypercube validation succeeded!) -``` -10×4 Array{Float64,2}: - 5.09089 0.997824 1.01815 0.980758 - 5.13971 1.02019 0.977637 1.00124 - 5.13209 0.974332 1.00058 0.99874 - 5.11529 0.998166 1.01825 1.01885 - 5.10542 1.01686 0.975993 0.991992 - 0.991075 0.993312 1.00667 1.05048 - 0.996443 1.02699 0.938742 0.98831 - 0.946917 0.989543 1.0056 0.999609 - 5.09963 1.00131 0.978803 0.984435 - 1.00892 0.998226 1.05538 0.994829 -``` - -A similar bucketed version is available for computation of medians: -```julia -dmedian_buckets(di, 10, mapping, [1,2,3,4]) -``` - -Note that the cluster medians are similar to means, except for the cases when -the cluster is formed by 2 actual data aggregations (e.g. on the second row), -where medians dodge the empty space in the middle of the data: -``` -10×4 Array{Float64,2}: - 1.97831 -0.0120118 9.98967 10.0161 - 7.99438 10.0263 9.9988 10.0033 - 3.27907 9.98728 10.0254 0.00444198 - 7.91739 -0.0623953 -0.0240277 10.0374 - 2.445 10.0101 -0.0471141 -0.0253346 - 10.0121 0.00935064 9.94992 0.0459787 - 10.0512 9.93359 0.0923141 9.91175 - 0.0675462 -0.0142712 9.93406 0.0343599 - 8.09972 -0.0217352 0.0575258 -0.010485 - -0.0183372 10.0392 -0.115253 10.0101 -``` diff --git a/docs/src/tutorials/03-distributed-data.jl b/docs/src/tutorials/03-distributed-data.jl new file mode 100644 index 00000000..4bb94a28 --- /dev/null +++ b/docs/src/tutorials/03-distributed-data.jl @@ -0,0 +1,122 @@ + +# # Tutorial 3: Distributed data processing and statistics + +# If you can get the data on a single machine, computation of various +# statistics can be performed using standard Julia functions. With large +# datasets that do not fit on a computer, things get more complicated. Luckily, +# many statistics and algorithms possess parallel, map-reduce-style +# implementations that can be used to address this problem. +# +# Here, we show how to use DistributedData package to run various operations on +# the datasets scattered over many computation nodes. For demonstration, we use +# a dataset that contains the "hypercube of clusters": + +using DistributedData +using Distributed + +import Random #src +Random.seed!(12345) #src +d = randn(10000, 4) .+ rand(0:1, 10000, 4) .* 10; + +# First, we distribute the dataset over the available workers to obtain a +# "handle": + +di = scatter_array(:distributed_d, d, workers()) + +# There are many functions to work with such data by "handles". For example, +# the `dstat` function computes sample statistics without fetching all data to +# a single node. You can use it in a way similar to aforementioned `dselect` +# and `dtransform_asinh`. The following code extracts means and standard +# deviations from the first 3 columns of a dataset distributed as `di`: + +dstat(di, [1, 2, 3]) +# +# ## Manual work with the DistributedData.jl package +# +# We will first show how to use the general framework to compute per-cluster +# statistics. DistributedData.jl exports the `dmapreduce` function that can be +# used as a very effective basic building block for running such computations. +# For example, you can efficiently compute a distributed mean of all your data +# as such: + +dmapreduce(di, sum, +) / dmapreduce(di, length, +) + +# The parameters of `dmapreduce` are, in order: +# - `di`, the dataset +# - `sum` or `length`, an unary "map" function -- during the computation, each +# piece of distributed data is first _paralelly_ processed by this function +# - `+`, a binary "reduction" or "folding" function -- the pieces of information +# processed by the map function are successively joined in pairs using this +# function, until there is only a single result left. This final result is +# also what `dmapreduce` returns. +# +# Above example thus reads: Sum all data on all workers, add up the intermediate +# results, and divide the final number to the sum of all lengths of data on the +# workers. +# +# Column-wise mean (as produced by `dstat`) is slightly more useful; we only need +# to split the computation on columns: + +dmapreduce(di, d -> mapslices(sum, d, dims = 1), +) ./ dmapreduce(di, x->size(x, 1), +) + +# Finally, for distributed computation of per-cluster mean, the clustering +# information needs to be distributed as well (Fortunately, that is easy, +# because the distributed `assign` does exactly that). +# +# First, compute the clustering: +mapping = assign(som, di) +dtransform(mapping, m -> meta_clusters[m]) + +# Now, the distributed computation is run on 2 scattered datasets. We employ a +# helper function `mapbuckets` which provides bucket-wise execution of a +# function, in a way very similar to `mapslices`. (In the example, we actually +# use `catmapbuckets` that concatenates the result into a nice array.) The +# following code produces a matrix of tuples `(sum, count)`, for separate +# clusters (in rows) and data columns (in columns): + +sumscounts = dmapreduce( + [di, mapping], + (d, mapping) -> + catmapbuckets((_, clData) -> (sum(clData), length(clData)), d, 10, mapping), + (a, b) -> (((as, al), (bs, bl)) -> ((as+bs), (al+bl))).(a, b), +) + +# With a bit of Julia, this can be aggregated to actual per-cluster means: + +cluster_means = [sum/count for (sum, count) in sumcounts] + +# Since we used the data from the hypercube dataset from the beginning of the +# tutorial, you should be able to recognize several clusters that perfectly +# match the hypercube vertices (although not all, because `k = 10` is not +# enough to capture all of the actual 16 existing clusters) +# +# Finally, we can remove the temporary data from workers to create free memory +# for other analyses: +unscatter(mapping) + +# ## Convenience statistical functions +# +# Notably, several of the most used statistical functions are available in +# DistributedData.jl in a form that can cope with distributed data. +# +# For example, you can run a distributed median computation as such: +dmedian(di, [1, 2, 3, 4]) + +# In the hypercube dataset, the medians are slightly off-center because there +# is a lot of empty space between the clusters. + +# `dstat` function has a bucketed variant that can split the statistics among +# different clusters. This computes the per-cluster standard deviations of the +# dataset: + +dstat_buckets(di, 10, mapping, [1, 2, 3, 4])[2] + +# In the result, we can count 4 "nice" clusters, and 6 clusters that span 2 of +# the original clusters, totally giving 16. (Hypercube validation succeeded!) +# +# A similar bucketed version is available for computation of medians: +dmedian_buckets(di, 10, mapping, [1, 2, 3, 4]) + +# Note that the cluster medians are similar to means, except for the cases when +# the cluster is formed by 2 actual data aggregations (e.g. on the second row), +# where medians dodge the empty space in the middle of the data: From ed8ab78fc9c3c15e218b66264825e46b559bbf9b Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 26 Aug 2025 13:59:57 +0200 Subject: [PATCH 34/61] fix things --- Project.toml | 7 +++- docs/Project.toml | 1 + docs/src/tutorials/02-flow-data.jl | 14 ++++++-- docs/src/tutorials/03-distributed-data.jl | 41 ++++++++++++++--------- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Project.toml b/Project.toml index f3b41d43..45f06f07 100644 --- a/Project.toml +++ b/Project.toml @@ -22,19 +22,24 @@ Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" +Distributed = "1.6" DistributedData = "0.1.4, 0.2" Distributions = "^0.21.1, 0.22, 0.23, 0.24, 0.25" DocStringExtensions = "0.9.5" +Downloads = "1.6" FCSFiles = "^0.1.1, 0.2" FileIO = "^1.0.7" JSON = "0.21" NearestNeighbors = "^0.4.3" +Random = "1.6" +Serialization = "1.6" StableRNGs = "1.0" XLSX = "0.8" julia = "1" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" @@ -42,4 +47,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["Aqua", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] +test = ["Aqua", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] diff --git a/docs/Project.toml b/docs/Project.toml index 67a97bf3..ae16ade9 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,5 +1,6 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" +Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 74d8fdcc..261bbe0e 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -8,21 +8,31 @@ using GigaSOM import Downloads: download +isfile("Levine_13dim.fcs") || download( + "https://github.com/lmweber/benchmark-data-Levine-13-dim/raw/refs/heads/master/data/Levine_13dim.fcs", + "Levine_13dim.fcs", +) + params, data = load_fcs("Levine_13dim.fcs") +@test length(params)==111 #src +@test size(data)==(167044, 14) #src + # `params` will now contain the list of FCS parameters; you can parse a lot of # interesting information from it using the [`fcs_column_metadata`](@ref) # function: fcs_column_metadata(params) +@test fcs_column_metadata(params).N[14] == "label" #src + # `data` is a matrix with cell expressions, one cell per row, one marker per # column. If you want to run SOM analysis on it, you can cluster and visualize # it just as in the previous tutorial, with one exception- we start with # cutting off the `label` column that contains `NaN` values: data = data[:, 1:13] -som = init(data, 16, 16) +som = init(data, 16, 16, seed = 12345) som = train(som, data) clusters = assign(som, data) e = embed(som, data) @@ -58,7 +68,7 @@ datainfo = load_fcs_distributed(:levine, ["Levine_13dim.fcs"]) # The following example exploits the possibility to actually split the data, # and processes the Levine dataset parallelly on 2 workers: -using Distributed +using Distributed, DistributedData addprocs(2) # add any number of CPUs/tasks/workers you have available @everywhere using GigaSOM # load GigaSOM also on the workers diff --git a/docs/src/tutorials/03-distributed-data.jl b/docs/src/tutorials/03-distributed-data.jl index 4bb94a28..7f9dc672 100644 --- a/docs/src/tutorials/03-distributed-data.jl +++ b/docs/src/tutorials/03-distributed-data.jl @@ -64,32 +64,43 @@ dmapreduce(di, d -> mapslices(sum, d, dims = 1), +) ./ dmapreduce(di, x->size(x, # because the distributed `assign` does exactly that). # # First, compute the clustering: +som = init(di, 10, 10, seed = 12345) +train(som, di) mapping = assign(som, di) + +# Given a metaclustering (as produced in the [flow cytometry +# tutorial](02-flow-data.md), we can transform the mapping to SOM clusters to +# the mapping to metaclusters. (For simplicity, we use a random metaclustering +# here.) + +meta_clusters = rand(1:5, 100) dtransform(mapping, m -> meta_clusters[m]) -# Now, the distributed computation is run on 2 scattered datasets. We employ a -# helper function `mapbuckets` which provides bucket-wise execution of a -# function, in a way very similar to `mapslices`. (In the example, we actually -# use `catmapbuckets` that concatenates the result into a nice array.) The -# following code produces a matrix of tuples `(sum, count)`, for separate -# clusters (in rows) and data columns (in columns): +# The computation is automatically run over the 2 distributed partitions of the +# dataset. +# +# To aggregate some statistic information about the clusters, we use a helper +# function `mapbuckets` which provides bucket-wise execution of any +# statistics-generating function, in a way very similar to `mapslices`. (In the +# example, we actually use `catmapbuckets` that concatenates the result into a +# nice array.) The following code produces a matrix of tuples `(sum, count)`, +# for separate clusters (in rows) and data columns (in columns): sumscounts = dmapreduce( [di, mapping], - (d, mapping) -> - catmapbuckets((_, clData) -> (sum(clData), length(clData)), d, 10, mapping), + (d, mapping) -> DistributedData.catmapbuckets( + (_, clust) -> (sum(clust), length(clust)), + d, + 5, + mapping, + ), (a, b) -> (((as, al), (bs, bl)) -> ((as+bs), (al+bl))).(a, b), ) -# With a bit of Julia, this can be aggregated to actual per-cluster means: - +# With a bit of extra programming, the gathered information can be aggregated +# to produce actual per-cluster means: cluster_means = [sum/count for (sum, count) in sumcounts] -# Since we used the data from the hypercube dataset from the beginning of the -# tutorial, you should be able to recognize several clusters that perfectly -# match the hypercube vertices (although not all, because `k = 10` is not -# enough to capture all of the actual 16 existing clusters) -# # Finally, we can remove the temporary data from workers to create free memory # for other analyses: unscatter(mapping) From e5c0dc093295dff87e1d6b779873533af7077228 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 26 Aug 2025 14:38:31 +0200 Subject: [PATCH 35/61] fix more tests --- Project.toml | 11 +++++++---- docs/src/tutorials/02-flow-data.jl | 6 +++--- docs/src/tutorials/03-distributed-data.jl | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Project.toml b/Project.toml index 45f06f07..3340b5e7 100644 --- a/Project.toml +++ b/Project.toml @@ -22,18 +22,21 @@ Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" -Distributed = "1.6" +Distributed = "1" DistributedData = "0.1.4, 0.2" Distributions = "^0.21.1, 0.22, 0.23, 0.24, 0.25" DocStringExtensions = "0.9.5" -Downloads = "1.6" +Downloads = "1" FCSFiles = "^0.1.1, 0.2" FileIO = "^1.0.7" JSON = "0.21" +LinearAlgebra = "1" NearestNeighbors = "^0.4.3" -Random = "1.6" -Serialization = "1.6" +Random = "1" +SHA = "0.7" +Serialization = "1" StableRNGs = "1.0" +Test = "1" XLSX = "0.8" julia = "1" diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 261bbe0e..58d1af38 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -129,9 +129,9 @@ end # extract information about FCS data columns from there. First, we read the # actual content using the XLSX package: -using XLSX -md = GigaSOM.DataFrame(readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)...) -panel = GigaSOM.DataFrame(readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)...) +import XLSX +md = GigaSOM.DataFrame(XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)...) +panel = GigaSOM.DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)...) # After that, we can get the parameter structure from the first FCS files: _, fcsParams = read_fcs_header(md[1, :file_name]) diff --git a/docs/src/tutorials/03-distributed-data.jl b/docs/src/tutorials/03-distributed-data.jl index 7f9dc672..1729109e 100644 --- a/docs/src/tutorials/03-distributed-data.jl +++ b/docs/src/tutorials/03-distributed-data.jl @@ -86,7 +86,7 @@ dtransform(mapping, m -> meta_clusters[m]) # nice array.) The following code produces a matrix of tuples `(sum, count)`, # for separate clusters (in rows) and data columns (in columns): -sumscounts = dmapreduce( +sums_counts = dmapreduce( [di, mapping], (d, mapping) -> DistributedData.catmapbuckets( (_, clust) -> (sum(clust), length(clust)), @@ -99,7 +99,7 @@ sumscounts = dmapreduce( # With a bit of extra programming, the gathered information can be aggregated # to produce actual per-cluster means: -cluster_means = [sum/count for (sum, count) in sumcounts] +cluster_means = [sum/count for (sum, count) in sums_counts] # Finally, we can remove the temporary data from workers to create free memory # for other analyses: From 40300e8d85a7e2f3794ea0b36b0c8ec19a982512 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 26 Aug 2025 14:49:55 +0200 Subject: [PATCH 36/61] fix spacing --- docs/src/tutorials/02-flow-data.jl | 11 +++++++---- docs/src/tutorials/03-distributed-data.jl | 10 +++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 58d1af38..8ead8e2b 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -102,7 +102,7 @@ e = gather_array(e) # # 10 cell surface markers (lineage markers) are used to identify different cell # populations. The dataset is described in two files: -# +# # - `PBMC8_panel.xlsx` (with antigen names categorized as lineage markers and # functional markers) # - `PBMC8_metadata.xlsx` (file names, sample IDs, condition IDs and patient @@ -130,8 +130,11 @@ end # actual content using the XLSX package: import XLSX -md = GigaSOM.DataFrame(XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)...) -panel = GigaSOM.DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)...) +md = GigaSOM.DataFrame( + XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)..., +) +panel = + GigaSOM.DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)...) # After that, we can get the parameter structure from the first FCS files: _, fcsParams = read_fcs_header(md[1, :file_name]) @@ -149,7 +152,7 @@ clean_names!(antigens) clean_names!(fcsAntigens) # ### Load and prepare the data -# +# # Now we have the vector of `fcsAntigens` that the FCS files store, and list of # `antigens` that we want to analyze. We continue by loading the data, reducing # it to the desired antigens and transforming it a bit: diff --git a/docs/src/tutorials/03-distributed-data.jl b/docs/src/tutorials/03-distributed-data.jl index 1729109e..fcdb8789 100644 --- a/docs/src/tutorials/03-distributed-data.jl +++ b/docs/src/tutorials/03-distributed-data.jl @@ -49,11 +49,11 @@ dmapreduce(di, sum, +) / dmapreduce(di, length, +) # processed by the map function are successively joined in pairs using this # function, until there is only a single result left. This final result is # also what `dmapreduce` returns. -# +# # Above example thus reads: Sum all data on all workers, add up the intermediate # results, and divide the final number to the sum of all lengths of data on the # workers. -# +# # Column-wise mean (as produced by `dstat`) is slightly more useful; we only need # to split the computation on columns: @@ -62,7 +62,7 @@ dmapreduce(di, d -> mapslices(sum, d, dims = 1), +) ./ dmapreduce(di, x->size(x, # Finally, for distributed computation of per-cluster mean, the clustering # information needs to be distributed as well (Fortunately, that is easy, # because the distributed `assign` does exactly that). -# +# # First, compute the clustering: som = init(di, 10, 10, seed = 12345) train(som, di) @@ -106,10 +106,10 @@ cluster_means = [sum/count for (sum, count) in sums_counts] unscatter(mapping) # ## Convenience statistical functions -# +# # Notably, several of the most used statistical functions are available in # DistributedData.jl in a form that can cope with distributed data. -# +# # For example, you can run a distributed median computation as such: dmedian(di, [1, 2, 3, 4]) From 647130f7eae673b47d8fe30d34af0aeb8239840d Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 12:47:39 +0200 Subject: [PATCH 37/61] add tests --- docs/src/tutorials/01-basic.jl | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index 4da2c3b4..d76510a1 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -13,10 +13,11 @@ # The main functions, listed by category, include: # # - [`load_fcs`](@ref) and [`load_fcs_distributed`](@ref) for loading the data -# - [`fcs_column_metadata`](@ref) and [`getMarkerNames`](@ref) for accessing -# the information about data columns stored in FCS files +# - [`fcs_column_metadata`](@ref) and [`fcs_metadata_marker_names`](@ref) for +# accessing the information about data columns stored in FCS files # - [`dselect`](@ref), [`dtransform_asinh`](@ref), [`dscale`](@ref) and similar -# functions for transforming, scaling and preparing the data +# functions from `DistributedData.jl` for transforming, scaling and preparing +# the data # - [`init`](@ref) for initializing the self-organizing map, [`train`](@ref) # for running the SOM training, [`assign`](@ref) for classification using the # trained SOM, and [`embed`](@ref) for dimensionality reduction to 2D @@ -36,8 +37,8 @@ # above in fact accept the `Dinfo` as argument, and often return another # `Dinfo` that describes the scattered result. # -# Most importantly, using `Dinfo` **prevents memory exhaustion at the -# master node**, which is a critical feature required to handle huge datasets. +# Most importantly, using `Dinfo` *prevents memory exhaustion at the +# master node*, which is a critical feature required to handle huge datasets. # # You can always collect the scattered data back into a matrix (if it fits to # your RAM) with `gather_array`, and utilize many other functions to manipulate @@ -73,10 +74,16 @@ som = train(som, d) som.codes +@test size(som.grid) == (400, 2) #src +@test size(som.codes) == (400, 4) #src +@test isapprox(sum(som.codes), 8560.881975246337) #src + # This information can be used to categorize the dataset into clusters: assign(som, d) +@test length(assign(som, d)) == 10000 #src + # In the result, `index` is a cluster ID for the original datapoint from `d` at # the same row. (As in the previous case, your numbers may differ.) # @@ -85,6 +92,8 @@ assign(som, d) # good-looking 2D scatterplot. e = embed(som, d) +@test isapprox(sum(e, dims = 1), [93072.76296681864 94834.6404517213]) #src + # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Gadfly`: From 6a4addf531432f73a781d06effe44ec9fc932be5 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 14:18:25 +0200 Subject: [PATCH 38/61] somewhat clean up the first tutorial --- Project.toml | 4 +++- docs/Project.toml | 2 ++ docs/make.jl | 20 ++++++++-------- docs/src/tutorials/01-basic.jl | 44 ++++++++++++++++++++++++++-------- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/Project.toml b/Project.toml index 3340b5e7..b0665e18 100644 --- a/Project.toml +++ b/Project.toml @@ -37,6 +37,7 @@ SHA = "0.7" Serialization = "1" StableRNGs = "1.0" Test = "1" +Vizagrams = "0.2" XLSX = "0.8" julia = "1" @@ -47,7 +48,8 @@ JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["Aqua", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] +test = ["Aqua", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "Vizagrams", "XLSX"] diff --git a/docs/Project.toml b/docs/Project.toml index ae16ade9..e71c347d 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,6 +1,8 @@ [deps] +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" +Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" diff --git a/docs/make.jl b/docs/make.jl index 767db13c..ae9e7ae1 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,21 +1,18 @@ using Documenter, Literate, GigaSOM -examples = sort( - filter( - x -> endswith(x, ".jl"), - readdir(joinpath(@__DIR__, "src", "tutorials"), join = true), - ), -) +tutorial_path = joinpath(@__DIR__, "src", "tutorials") + +tutorials = sort(filter(x -> endswith(x, ".jl"), readdir(tutorial_path, join = true))) -for example in examples +for tutorial in tutorials Literate.markdown( - example, - joinpath(@__DIR__, "src"), + tutorial, + tutorial_path, repo_root_url = "https://github.com/LCSB-BioCore/GitaSOM.jl/blob/master", ) end -tutorial_mds = first.(splitext.(basename.(examples))) .* ".md" +tutorial_mds = "tutorials/" .* first.(splitext.(basename.(tutorials))) .* ".md" withenv("COLUMNS" => 150) do makedocs( @@ -24,6 +21,9 @@ withenv("COLUMNS" => 150) do ansicolor = true, canonical = "https://lcsb-biocore.github.io/GigaSOM.jl/stable/", assets = ["assets/gigasomlogotransp.ico"], + example_size_threshold = nothing, + size_threshold_warn = 10 * 1024 * 1024, + size_threshold = nothing, ), sitename = "GigaSOM.jl", pages = [ diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index d76510a1..6857a36e 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -15,9 +15,9 @@ # - [`load_fcs`](@ref) and [`load_fcs_distributed`](@ref) for loading the data # - [`fcs_column_metadata`](@ref) and [`fcs_metadata_marker_names`](@ref) for # accessing the information about data columns stored in FCS files -# - [`dselect`](@ref), [`dtransform_asinh`](@ref), [`dscale`](@ref) and similar -# functions from `DistributedData.jl` for transforming, scaling and preparing -# the data +# - `dselect`, `dtransform_asinh`, `dscale` and similar functions from +# [`DistributedData.jl`](https://lcsb-biocore.github.io/DistributedData.jl/stable/) +# for transforming, scaling and preparing the data # - [`init`](@ref) for initializing the self-organizing map, [`train`](@ref) # for running the SOM training, [`assign`](@ref) for classification using the # trained SOM, and [`embed`](@ref) for dimensionality reduction to 2D @@ -95,14 +95,38 @@ e = embed(som, d) @test isapprox(sum(e, dims = 1), [93072.76296681864 94834.6404517213]) #src # The 2D coordinates may be plotted using any standard plotting library. In the -# following example we show how to do that with `Gadfly`: +# following example we show how to do that with `Vizagrams.jl`: -# TODO -# Pkg.add("Gadfly") -# Pkg.add("Cairo") -# using Gadfly -# import Cairo -# draw(PNG("test.png",20cm,20cm), plot(x=e[:,1], y=e[:,2], color=d[:,1])) +using Vizagrams, DataFrames + +embedded_data = [DataFrame(e, [:x, :y]) DataFrame(d, [:v1, :v2, :v3, :v4])] + +draw( + Plot( + data = embedded_data, + x = (field = :x,), + y = (field = :y,), + color = (field = :v1, scale_range = :YlGnBu), + graphic = Circle(r = 1), + ), + height = 512, +) # The output shows all 16 ($16 = 2^d$ where $d =4$), colored by their position # in the 1st dimension in the original space. +# +# For complete clarity on the underlying data structures, we can also plot the +# self-organizing map that "describes" the space: + +som_data = [DataFrame(som.grid, [:x, :y]) DataFrame(som.codes, [:v1, :v2, :v3, :v4])] + +draw( + Plot( + data = som_data, + x = (field = :x,), + y = (field = :y,), + color = (field = :v1, scale_range = :YlGnBu), + graphic = Circle(r = 4), + ), + height = 512, +) From a6905c84624c31f932fb25ffcadaa0251647c402 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:01:21 +0200 Subject: [PATCH 39/61] fix tut 2 --- docs/src/tutorials/02-flow-data.jl | 12 +++++------- src/load.jl | 4 ++-- test/testLoadPBMC8.jl | 4 ++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 8ead8e2b..780a8c8e 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -130,17 +130,15 @@ end # actual content using the XLSX package: import XLSX -md = GigaSOM.DataFrame( - XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)..., -) -panel = - GigaSOM.DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)...) +using DataFrames +md = DataFrame(XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = true)) +panel = DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)) # After that, we can get the parameter structure from the first FCS files: -_, fcsParams = read_fcs_header(md[1, :file_name]) +_, fcsParams = GigaSOM.read_fcs_header(md[1, :file_name]) # ...and continue with extracting marker names using the prepared functions: -_, fcsAntigens = fcs_header_marker_names(fcs_column_metadata(fcsParams)) +_, fcsAntigens = GigaSOM.fcs_metadata_marker_names(fcs_column_metadata(fcsParams)) # Now, TO see which antigens we want to use (assume we want only the lineage # markers): diff --git a/src/load.jl b/src/load.jl index 0e41e1a1..4dba0c2f 100644 --- a/src/load.jl +++ b/src/load.jl @@ -3,7 +3,7 @@ $(TYPEDSIGNATURES) Efficiently extract data offsets and keyword dictionary from an FCS file. """ -function load_fcs_header(fn::String)::Tuple{Vector{Int},Dict{String,String}} +function read_fcs_header(fn::String)::Tuple{Vector{Int},Dict{String,String}} open(fn) do io offsets = FCSFiles.parse_header(io) params = FCSFiles.parse_text(io, offsets[1], offsets[2]) @@ -53,7 +53,7 @@ Load cell counts in many FCS files at once. Useful as input for `slicesof`. function read_fcs_sizes(fns::Vector{String})::Vector{Int} [( begin - o, s = load_fcs_header(fn) + o, s = read_fcs_header(fn) read_fcs_size(o, s)[1] end ) for fn in fns] diff --git a/test/testLoadPBMC8.jl b/test/testLoadPBMC8.jl index 3e0b2232..af9b5f49 100644 --- a/test/testLoadPBMC8.jl +++ b/test/testLoadPBMC8.jl @@ -1,5 +1,5 @@ -import GigaSOM: load_fcs_header +import GigaSOM: read_fcs_header # This loads the PBMC8 dataset that is used for later tests @@ -59,7 +59,7 @@ md = DataFrame(XLSX.readtable("PBMC8_metadata.xlsx", "Sheet1", infer_eltypes = t panel = DataFrame(XLSX.readtable("PBMC8_panel.xlsx", "Sheet1", infer_eltypes = true)) antigens = panel[panel[:, :Lineage] .== 1, :Antigen] -_, fcsParams = load_fcs_header(md[1, :file_name]) +_, fcsParams = read_fcs_header(md[1, :file_name]) _, fcsAntigens = fcs_metadata_marker_names(fcs_column_metadata(fcsParams)) clean_names!(antigens) clean_names!(fcsAntigens) From 7cc4cc2594eb97917f22d9e14e63b38fc9569a69 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:39:20 +0200 Subject: [PATCH 40/61] fix tut 3 --- docs/src/tutorials/03-distributed-data.jl | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/src/tutorials/03-distributed-data.jl b/docs/src/tutorials/03-distributed-data.jl index fcdb8789..eb799da8 100644 --- a/docs/src/tutorials/03-distributed-data.jl +++ b/docs/src/tutorials/03-distributed-data.jl @@ -64,6 +64,7 @@ dmapreduce(di, d -> mapslices(sum, d, dims = 1), +) ./ dmapreduce(di, x->size(x, # because the distributed `assign` does exactly that). # # First, compute the clustering: +using GigaSOM som = init(di, 10, 10, seed = 12345) train(som, di) mapping = assign(som, di) @@ -73,7 +74,10 @@ mapping = assign(som, di) # the mapping to metaclusters. (For simplicity, we use a random metaclustering # here.) -meta_clusters = rand(1:5, 100) +import Random +Random.seed!(12345); # set a seed for reproducibility of rand() + +meta_clusters = rand(1:5, 100); dtransform(mapping, m -> meta_clusters[m]) # The computation is automatically run over the 2 distributed partitions of the @@ -101,10 +105,6 @@ sums_counts = dmapreduce( # to produce actual per-cluster means: cluster_means = [sum/count for (sum, count) in sums_counts] -# Finally, we can remove the temporary data from workers to create free memory -# for other analyses: -unscatter(mapping) - # ## Convenience statistical functions # # Notably, several of the most used statistical functions are available in @@ -120,14 +120,20 @@ dmedian(di, [1, 2, 3, 4]) # different clusters. This computes the per-cluster standard deviations of the # dataset: -dstat_buckets(di, 10, mapping, [1, 2, 3, 4])[2] +(means, sds) = dstat_buckets(di, 5, mapping, [1, 2, 3, 4]) # In the result, we can count 4 "nice" clusters, and 6 clusters that span 2 of # the original clusters, totally giving 16. (Hypercube validation succeeded!) # # A similar bucketed version is available for computation of medians: -dmedian_buckets(di, 10, mapping, [1, 2, 3, 4]) +dmedian_buckets(di, 5, mapping, [1, 2, 3, 4]) # Note that the cluster medians are similar to means, except for the cases when # the cluster is formed by 2 actual data aggregations (e.g. on the second row), # where medians dodge the empty space in the middle of the data: + +# ## Cleaning up + +# Finally, we can remove the temporary data from workers to create free memory +# for other analyses: +unscatter(mapping) From 050379ca441982eccfc10161eb011fac33810845 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:45:20 +0200 Subject: [PATCH 41/61] add clustering to test deps --- Project.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index b0665e18..8cb1ccf6 100644 --- a/Project.toml +++ b/Project.toml @@ -20,6 +20,7 @@ StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" [compat] Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" +Clustering = "0.15.8" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" Distributed = "1" @@ -37,12 +38,13 @@ SHA = "0.7" Serialization = "1" StableRNGs = "1.0" Test = "1" -Vizagrams = "0.2" +Vizagrams = "0.2.15" XLSX = "0.8" julia = "1" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -52,4 +54,4 @@ Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["Aqua", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "Vizagrams", "XLSX"] +test = ["Aqua", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "Vizagrams", "XLSX"] From 944b6de0d0b5fb572b864039cb502ecb5ff0f5d5 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:56:42 +0200 Subject: [PATCH 42/61] add docs dep on DD --- docs/Project.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/Project.toml b/docs/Project.toml index e71c347d..df123173 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,5 +1,6 @@ [deps] DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" From afe41a803eac7f46d930f3741cdcdeb0a6762698 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:56:47 +0200 Subject: [PATCH 43/61] reformat the readme header --- README.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8a5e8b8d..b1a90486 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,27 @@ -![GigaSOM.jl](https://webdav-r3lab.uni.lu/public/GigaSOM/img/logo-GigaSOM.jl.png?maxAge=0) -# GigaSOM.jl
Huge-scale, high-performance flow cytometry clustering +# GigaSOM.jl:Huge-scale, high-performance flow cytometry clustering -GigaSOM is a Julia toolkit for clustering and visualisation of really large cytometry data. Most generally, it can load FCS files, perform transformation and cleaning operations in their contents, run FlowSOM-style clustering, and visualize and export the results. GigaSOM is distributed and parallel in nature, which makes processing huge datasets a breeze -- a hundred of millions of cells with a few dozen parameters can be clustered and visualized in a few minutes. +GigaSOM is a Julia toolkit for clustering and visualisation of really large datasets from flow cytometry. In overview, it can load FCS files, perform transformation and cleaning operations in their contents, run FlowSOM-style clustering, and visualize and export the results. GigaSOM is distributed and parallel in nature, which makes processing of huge datasets a breeze -- a few hundred of millions of cells with a few dozen parameters may be clustered and visualized in minutes. -| **Documentation** | **Test Coverage** | **CI** | **SciCrunch** | +[docs-img-stable]: https://img.shields.io/badge/docs-stable-blue.svg +[docs-url-stable]: https://lcsb-biocore.github.io/GigaSOM.jl +[docs-img-dev]: https://img.shields.io/badge/docs-latest-0af.svg +[docs-url-dev]: https://lcsb-biocore.github.io/GigaSOM.jl/dev/ +[docs-img-tutorials]: https://img.shields.io/badge/docs-tutorials-0ee.svg +[docs-img-tutorials]: https://lcsb-biocore.github.io/GigaSOM.jl/stable/tutorials/ + +[ci-img]: https://github.com/LCSB-BioCore/GigaSOM.jl/actions/workflows/ci.yml/badge.svg?branch=master +[ci-url]: https://github.com/LCSB-BioCore/GigaSOM.jl/actions/workflows/ci.yml + +[cov-img]: https://codecov.io/gh/LCSB-BioCore/GigaSOM.jl/branch/master/graph/badge.svg?token=H3WSWOBD7L +[cov-url]: https://codecov.io/gh/LCSB-BioCore/GigaSOM.jl + +[rrid-img]: https://img.shields.io/badge/RRID-SCR__019020-72c02c +[rrid-url]: https://scicrunch.org/resolver/RRID:SCR_019020 + +| **Documentation** | **Test Coverage** | **CI** | **SciCrunch RRID** | |:-----------------:|:-----------------:|:-----------------------------------------------------:|:--------:| -| [![doc](https://img.shields.io/badge/doc-GigaSOM-blue)](http://git.io/GigaSOM.jl) | [![coverage status](http://codecov.io/github/LCSB-BioCore/GigaSOM.jl/coverage.svg?branch=master)](http://codecov.io/github/LCSB-BioCore/GigaSOM.jl?branch=master) | [![linux](https://github.com/LCSB-BioCore/GigaSOM.jl/workflows/CI/badge.svg?branch=master)](https://github.com/LCSB-BioCore/GigaSOM.jl/actions) | [![rrid](https://img.shields.io/badge/RRID-SCR__019020-72c02c)](https://scicrunch.org/resolver/RRID:SCR_019020) | +| [![stable documentation][docs-img-stable]][docs-url-stable] [![dev documentation][docs-img-dev]][docs-url-dev] [![tutorials][docs-img-tutorials]][docs-url-tutorials] | [![coverage status][cov-img]][cov-url] | [![CI status][ci-img]][ci-url] | [![rrid][rrid-img]][rrid-url] | If you use GigaSOM.jl and want to refer to it in your work, use the following citation format (also available as BibTeX in [gigasom.bib](gigasom.bib)): From a34f1415f93802db69d47a26a021dbd1c3c6e3ef Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:59:17 +0200 Subject: [PATCH 44/61] logo goes back --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b1a90486..c0e412fb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ +

+ +
+ # GigaSOM.jl:Huge-scale, high-performance flow cytometry clustering GigaSOM is a Julia toolkit for clustering and visualisation of really large datasets from flow cytometry. In overview, it can load FCS files, perform transformation and cleaning operations in their contents, run FlowSOM-style clustering, and visualize and export the results. GigaSOM is distributed and parallel in nature, which makes processing of huge datasets a breeze -- a few hundred of millions of cells with a few dozen parameters may be clustered and visualized in minutes. @@ -7,8 +11,8 @@ GigaSOM is a Julia toolkit for clustering and visualisation of really large data [docs-url-stable]: https://lcsb-biocore.github.io/GigaSOM.jl [docs-img-dev]: https://img.shields.io/badge/docs-latest-0af.svg [docs-url-dev]: https://lcsb-biocore.github.io/GigaSOM.jl/dev/ -[docs-img-tutorials]: https://img.shields.io/badge/docs-tutorials-0ee.svg -[docs-img-tutorials]: https://lcsb-biocore.github.io/GigaSOM.jl/stable/tutorials/ +[docs-img-tutorials]: https://img.shields.io/badge/docs-tutorial-0dd.svg +[docs-url-tutorials]: https://lcsb-biocore.github.io/GigaSOM.jl/stable/tutorials/ [ci-img]: https://github.com/LCSB-BioCore/GigaSOM.jl/actions/workflows/ci.yml/badge.svg?branch=master [ci-url]: https://github.com/LCSB-BioCore/GigaSOM.jl/actions/workflows/ci.yml From ca15ee4f95acae83f45e20f99c275231f2a5348f Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 15:59:37 +0200 Subject: [PATCH 45/61] table goes up --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c0e412fb..89690087 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ # GigaSOM.jl:Huge-scale, high-performance flow cytometry clustering -GigaSOM is a Julia toolkit for clustering and visualisation of really large datasets from flow cytometry. In overview, it can load FCS files, perform transformation and cleaning operations in their contents, run FlowSOM-style clustering, and visualize and export the results. GigaSOM is distributed and parallel in nature, which makes processing of huge datasets a breeze -- a few hundred of millions of cells with a few dozen parameters may be clustered and visualized in minutes. - [docs-img-stable]: https://img.shields.io/badge/docs-stable-blue.svg [docs-url-stable]: https://lcsb-biocore.github.io/GigaSOM.jl [docs-img-dev]: https://img.shields.io/badge/docs-latest-0af.svg @@ -27,6 +25,8 @@ GigaSOM is a Julia toolkit for clustering and visualisation of really large data |:-----------------:|:-----------------:|:-----------------------------------------------------:|:--------:| | [![stable documentation][docs-img-stable]][docs-url-stable] [![dev documentation][docs-img-dev]][docs-url-dev] [![tutorials][docs-img-tutorials]][docs-url-tutorials] | [![coverage status][cov-img]][cov-url] | [![CI status][ci-img]][ci-url] | [![rrid][rrid-img]][rrid-url] | +GigaSOM is a Julia toolkit for clustering and visualisation of really large datasets from flow cytometry. In overview, it can load FCS files, perform transformation and cleaning operations in their contents, run FlowSOM-style clustering, and visualize and export the results. GigaSOM is distributed and parallel in nature, which makes processing of huge datasets a breeze -- a few hundred of millions of cells with a few dozen parameters may be clustered and visualized in minutes. + If you use GigaSOM.jl and want to refer to it in your work, use the following citation format (also available as BibTeX in [gigasom.bib](gigasom.bib)): > Miroslav Kratochvíl, Oliver Hunewald, Laurent Heirendt, Vasco Verissimo, Jiří Vondrášek, Venkata P Satagopam, Reinhard Schneider, Christophe Trefois, Markus Ollert. *GigaSOM.jl: High-performance clustering and visualization of huge cytometry datasets.* GigaScience, Volume 9, Issue 11, November 2020, giaa127, https://doi.org/10.1093/gigascience/giaa127 From 1da2200217eec5ce1d372a99bd89894ff7ec6e41 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 16:02:53 +0200 Subject: [PATCH 46/61] kill unstable approximate tests --- docs/src/tutorials/01-basic.jl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index 6857a36e..b628b326 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -76,7 +76,8 @@ som.codes @test size(som.grid) == (400, 2) #src @test size(som.codes) == (400, 4) #src -@test isapprox(sum(som.codes), 8560.881975246337) #src +@test minimum(som.codes) > -3 #src +@test maximum(som.codes) < 13 #src # This information can be used to categorize the dataset into clusters: @@ -92,7 +93,8 @@ assign(som, d) # good-looking 2D scatterplot. e = embed(som, d) -@test isapprox(sum(e, dims = 1), [93072.76296681864 94834.6404517213]) #src +@test minimum(e) > -3 #src +@test maximum(e) < 22 #src # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Vizagrams.jl`: From 55ead011a0f5aed4b7da38c05d2e6d6eb42964a2 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Wed, 27 Aug 2025 16:07:44 +0200 Subject: [PATCH 47/61] space --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 89690087..2346538f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ -# GigaSOM.jl:Huge-scale, high-performance flow cytometry clustering +# GigaSOM.jl: Huge-scale, high-performance flow cytometry clustering [docs-img-stable]: https://img.shields.io/badge/docs-stable-blue.svg [docs-url-stable]: https://lcsb-biocore.github.io/GigaSOM.jl From dda216eeaf51dac6838dac00205cd3c995851204 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 28 Aug 2025 09:20:46 +0200 Subject: [PATCH 48/61] switch plotting to AoG --- Project.toml | 6 +++--- docs/Project.toml | 2 ++ docs/src/tutorials/01-basic.jl | 33 ++++++++++++--------------------- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Project.toml b/Project.toml index 8cb1ccf6..0e092319 100644 --- a/Project.toml +++ b/Project.toml @@ -18,6 +18,7 @@ Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" [compat] +AlgebraOfGraphics = "0.11.7" Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" Clustering = "0.15.8" @@ -38,11 +39,11 @@ SHA = "0.7" Serialization = "1" StableRNGs = "1.0" Test = "1" -Vizagrams = "0.2.15" XLSX = "0.8" julia = "1" [extras] +AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" @@ -50,8 +51,7 @@ JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["Aqua", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "Vizagrams", "XLSX"] +test = ["AlgebraOfGraphics", "Aqua", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] diff --git a/docs/Project.toml b/docs/Project.toml index df123173..67950ba6 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,4 +1,6 @@ [deps] +AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index b628b326..a5ee9757 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -99,20 +99,11 @@ e = embed(som, d) # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Vizagrams.jl`: -using Vizagrams, DataFrames +using AlgebraOfGraphics, DataFrames embedded_data = [DataFrame(e, [:x, :y]) DataFrame(d, [:v1, :v2, :v3, :v4])] -draw( - Plot( - data = embedded_data, - x = (field = :x,), - y = (field = :y,), - color = (field = :v1, scale_range = :YlGnBu), - graphic = Circle(r = 1), - ), - height = 512, -) +draw(data(embedded_data)*mapping(:x, :y, color = :v1)*visual(Scatter, markersize = 2)) # The output shows all 16 ($16 = 2^d$ where $d =4$), colored by their position # in the 1st dimension in the original space. @@ -122,13 +113,13 @@ draw( som_data = [DataFrame(som.grid, [:x, :y]) DataFrame(som.codes, [:v1, :v2, :v3, :v4])] -draw( - Plot( - data = som_data, - x = (field = :x,), - y = (field = :y,), - color = (field = :v1, scale_range = :YlGnBu), - graphic = Circle(r = 4), - ), - height = 512, -) +draw(data(som_data)*mapping(:x, :y, color = :v1)*visual(Scatter, markersize = 20)) + +# To be completely clear about cluster occupancy, we can also compute the +# assignment counts and highlight them in the plot: +som_data[!, :count] .= 0 +for i in assign(som, d) + som_data[i, :count] += 1 +end + +draw(data(som_data)*mapping(:x, :y, color = :v1, markersize = :count)*visual(Scatter)) From 833e1801901feafbe3f57d70da98e1276e863295 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 28 Aug 2025 09:37:02 +0200 Subject: [PATCH 49/61] fix deps --- Project.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 0e092319..8ba1cd30 100644 --- a/Project.toml +++ b/Project.toml @@ -21,6 +21,7 @@ StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" AlgebraOfGraphics = "0.11.7" Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" +CairoMakie = "0.15.6" Clustering = "0.15.8" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" @@ -45,6 +46,7 @@ julia = "1" [extras] AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" @@ -54,4 +56,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["AlgebraOfGraphics", "Aqua", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] +test = ["AlgebraOfGraphics", "Aqua", "CairoMakie", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] From 2d1e6fcc8854d069e6e48c7031f141ae9814b09c Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 28 Aug 2025 09:37:57 +0200 Subject: [PATCH 50/61] whops import makie --- docs/src/tutorials/01-basic.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index a5ee9757..d6e76e20 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -99,7 +99,7 @@ e = embed(som, d) # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Vizagrams.jl`: -using AlgebraOfGraphics, DataFrames +using AlgebraOfGraphics, CairoMakie, DataFrames embedded_data = [DataFrame(e, [:x, :y]) DataFrame(d, [:v1, :v2, :v3, :v4])] From 1ffc3915724b2d097367b25497c03a1c62746868 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 28 Aug 2025 09:46:13 +0200 Subject: [PATCH 51/61] rename mapping to avoid conflict with AoG --- docs/src/tutorials/03-distributed-data.jl | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/src/tutorials/03-distributed-data.jl b/docs/src/tutorials/03-distributed-data.jl index eb799da8..0587008b 100644 --- a/docs/src/tutorials/03-distributed-data.jl +++ b/docs/src/tutorials/03-distributed-data.jl @@ -67,18 +67,18 @@ dmapreduce(di, d -> mapslices(sum, d, dims = 1), +) ./ dmapreduce(di, x->size(x, using GigaSOM som = init(di, 10, 10, seed = 12345) train(som, di) -mapping = assign(som, di) +clustering = assign(som, di) # Given a metaclustering (as produced in the [flow cytometry -# tutorial](02-flow-data.md), we can transform the mapping to SOM clusters to -# the mapping to metaclusters. (For simplicity, we use a random metaclustering +# tutorial](02-flow-data.md), we can transform the clustering to SOM clusters to +# the clustering to metaclusters. (For simplicity, we use a random metaclustering # here.) import Random Random.seed!(12345); # set a seed for reproducibility of rand() meta_clusters = rand(1:5, 100); -dtransform(mapping, m -> meta_clusters[m]) +dtransform(clustering, m -> meta_clusters[m]) # The computation is automatically run over the 2 distributed partitions of the # dataset. @@ -91,12 +91,12 @@ dtransform(mapping, m -> meta_clusters[m]) # for separate clusters (in rows) and data columns (in columns): sums_counts = dmapreduce( - [di, mapping], - (d, mapping) -> DistributedData.catmapbuckets( + [di, clustering], + (d, clustering) -> DistributedData.catmapbuckets( (_, clust) -> (sum(clust), length(clust)), d, 5, - mapping, + clustering, ), (a, b) -> (((as, al), (bs, bl)) -> ((as+bs), (al+bl))).(a, b), ) @@ -120,13 +120,13 @@ dmedian(di, [1, 2, 3, 4]) # different clusters. This computes the per-cluster standard deviations of the # dataset: -(means, sds) = dstat_buckets(di, 5, mapping, [1, 2, 3, 4]) +(means, sds) = dstat_buckets(di, 5, clustering, [1, 2, 3, 4]) # In the result, we can count 4 "nice" clusters, and 6 clusters that span 2 of # the original clusters, totally giving 16. (Hypercube validation succeeded!) # # A similar bucketed version is available for computation of medians: -dmedian_buckets(di, 5, mapping, [1, 2, 3, 4]) +dmedian_buckets(di, 5, clustering, [1, 2, 3, 4]) # Note that the cluster medians are similar to means, except for the cases when # the cluster is formed by 2 actual data aggregations (e.g. on the second row), @@ -136,4 +136,4 @@ dmedian_buckets(di, 5, mapping, [1, 2, 3, 4]) # Finally, we can remove the temporary data from workers to create free memory # for other analyses: -unscatter(mapping) +unscatter(clustering) From e2d820dc9a32ac89bbc6740db062a2afd492a480 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 28 Aug 2025 09:47:22 +0200 Subject: [PATCH 52/61] avoid `data` from conflicting with AoG --- docs/src/tutorials/02-flow-data.jl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 780a8c8e..faa9e8c6 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -13,10 +13,10 @@ isfile("Levine_13dim.fcs") || download( "Levine_13dim.fcs", ) -params, data = load_fcs("Levine_13dim.fcs") +params, expressions = load_fcs("Levine_13dim.fcs") @test length(params)==111 #src -@test size(data)==(167044, 14) #src +@test size(expressions)==(167044, 14) #src # `params` will now contain the list of FCS parameters; you can parse a lot of # interesting information from it using the [`fcs_column_metadata`](@ref) @@ -26,16 +26,16 @@ fcs_column_metadata(params) @test fcs_column_metadata(params).N[14] == "label" #src -# `data` is a matrix with cell expressions, one cell per row, one marker per +# `expressions` is a matrix with cell expressions, one cell per row, one marker per # column. If you want to run SOM analysis on it, you can cluster and visualize # it just as in the previous tutorial, with one exception- we start with # cutting off the `label` column that contains `NaN` values: -data = data[:, 1:13] -som = init(data, 16, 16, seed = 12345) -som = train(som, data) -clusters = assign(som, data) -e = embed(som, data) +expressions = expressions[:, 1:13] +som = init(expressions, 16, 16, seed = 12345) +som = train(som, expressions) +clusters = assign(som, expressions) +e = embed(som, expressions) # ... etc, you can save the results and plot them as needed. # From 44173f7a86e3a1493fa9385351d6c7031577438c Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Thu, 28 Aug 2025 10:15:55 +0200 Subject: [PATCH 53/61] more docs --- docs/Project.toml | 3 ++ docs/src/tutorials/02-flow-data.jl | 49 +++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 67950ba6..e76a4dde 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,7 +1,9 @@ [deps] AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +Distances = "b4f34e82-e78d-54a5-968a-f98e89d6e8f7" DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" @@ -9,3 +11,4 @@ Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" +XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index faa9e8c6..04d60069 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -37,7 +37,9 @@ som = train(som, expressions) clusters = assign(som, expressions) e = embed(som, expressions) -# ... etc, you can save the results and plot them as needed. +# ... etc, you can save the results and plot them as needed. We demonstrate the +# plotting below. + # # ## Working with distributed data # @@ -175,12 +177,30 @@ dscale(di, cols) # ### Train a self-organizing map (SOM) # # With the data prepared, running the SOM algorithm is straightforward: -som = init(di, 16, 16, seed = 12345) +som = init(di, 20, 20, seed = 12345) som = train(som, di, epochs = 20) # Finally, we can calculate the clustering: som_clusters = assign(som, di) +# Among other, the ability to collect the data also allows us to plot it using +# general plotting libraries. (With much larger datasets, a more complicated +# setup is needed where the plotting is done by parts.) In this case, we simply +# collect the expressions and embedding into a single dataframe and plot them +# as usual: + +using AlgebraOfGraphics, CairoMakie, DataFrames + +e = embed(som, di) + +plot_data = [DataFrame(gather_array(di), antigens) DataFrame(gather_array(e), [:x, :y])] + +draw( + data(plot_data)*mapping(:x, :y, color = :CD3)*visual(Scatter, markersize = 2), + scales(Color = (; colormap = :RdYlBu)), +) + + # ### FlowSOM-style metaclustering # # One disadvantage of SOMs is that they output a large amount of small clusters @@ -193,7 +213,7 @@ som_clusters = assign(som, di) using Clustering import Distances -metaClusters = cutree( +metaclusters = cutree( k = 10, hclust(linkage = :average, GigaSOM.distance_matrix(Distances.Euclidean())(som.codes)), ) @@ -201,12 +221,19 @@ metaClusters = cutree( # The `metaClusters` represent membership of the SOM codes in cluster; these # can be expanded to membership of all cells using [`assign`](@ref): -mapping = gather_array(assign(som, di), free = true) -clusters = metaClusters[mapping] +clustering = gather_array(assign(som, di), free = true) +plot_data[!, :cluster] = Symbol.(metaclusters[clustering]) + +# The plot data column `cluster` now contains classification of each cell in +# the dataset. We can plot it as follows: +draw( + data(plot_data)*mapping(:x, :y, color = :cluster)*visual( + Scatter, + markersize = 2, + colormap = :Set3_10, + ), +) -# `clusters` now contain integers from `1` to `10` that classify each cell in -# the dataset. -# -# (The argument `free=true` of `gather_array` automatically removes the -# distributed data from workers after collecting, which saves their memory for -# other datasets.) +# (Note: The argument `free=true` of `gather_array` automatically removes the +# distributed data from workers after collecting, which frees the memory for +# use with other datasets.) From 9b47ed51c7fb75503f394896198650c26368b01c Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 30 Sep 2025 10:56:48 +0200 Subject: [PATCH 54/61] try switching to GLMakie --- Project.toml | 6 +++--- docs/Project.toml | 2 +- docs/src/tutorials/01-basic.jl | 2 +- docs/src/tutorials/02-flow-data.jl | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Project.toml b/Project.toml index 8ba1cd30..2ca8818e 100644 --- a/Project.toml +++ b/Project.toml @@ -21,7 +21,6 @@ StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" AlgebraOfGraphics = "0.11.7" Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" -CairoMakie = "0.15.6" Clustering = "0.15.8" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" @@ -32,6 +31,7 @@ DocStringExtensions = "0.9.5" Downloads = "1" FCSFiles = "^0.1.1, 0.2" FileIO = "^1.0.7" +GLMakie = "0.13.6" JSON = "0.21" LinearAlgebra = "1" NearestNeighbors = "^0.4.3" @@ -46,9 +46,9 @@ julia = "1" [extras] AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" -CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" @@ -56,4 +56,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["AlgebraOfGraphics", "Aqua", "CairoMakie", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] +test = ["AlgebraOfGraphics", "Aqua", "GLMakie", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] diff --git a/docs/Project.toml b/docs/Project.toml index e76a4dde..c260ef04 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,6 +1,5 @@ [deps] AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" -CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Distances = "b4f34e82-e78d-54a5-968a-f98e89d6e8f7" @@ -8,6 +7,7 @@ DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index d6e76e20..957535ba 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -99,7 +99,7 @@ e = embed(som, d) # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with `Vizagrams.jl`: -using AlgebraOfGraphics, CairoMakie, DataFrames +using AlgebraOfGraphics, GLMakie, DataFrames embedded_data = [DataFrame(e, [:x, :y]) DataFrame(d, [:v1, :v2, :v3, :v4])] diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 04d60069..612186a0 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -189,7 +189,7 @@ som_clusters = assign(som, di) # collect the expressions and embedding into a single dataframe and plot them # as usual: -using AlgebraOfGraphics, CairoMakie, DataFrames +using AlgebraOfGraphics, GLMakie, DataFrames e = embed(som, di) From 3383feb15b5805e6137304d968d0d269741afdeb Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 30 Sep 2025 11:02:44 +0200 Subject: [PATCH 55/61] rm vizagrams leftovers --- docs/Project.toml | 1 - docs/src/tutorials/01-basic.jl | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index c260ef04..475f6d08 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -10,5 +10,4 @@ Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" -Vizagrams = "8c229dad-8b3a-4031-83d6-73545c88426d" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index 957535ba..54cb8ba4 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -97,7 +97,7 @@ e = embed(som, d) @test maximum(e) < 22 #src # The 2D coordinates may be plotted using any standard plotting library. In the -# following example we show how to do that with `Vizagrams.jl`: +# following example we show how to do that with AlgebraOfGraphics package: using AlgebraOfGraphics, GLMakie, DataFrames From d9bd8b3add3b63879de11e182dbf5e4054560661 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 30 Sep 2025 11:08:46 +0200 Subject: [PATCH 56/61] wrap the doc building in xfvb --- .github/workflows/docs.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e0cab802..a2b0d503 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,9 +16,13 @@ jobs: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@latest - uses: julia-actions/cache@v2 + - name: Install binary dependencies + run: sudo apt-get update && sudo apt-get install -y xorg-dev mesa-utils xvfb libgl1 freeglut3-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev - name: Install dependencies run: julia --color=yes --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy env: DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} # For authentication with SSH deploy key - run: julia --color=yes --project=docs/ docs/make.jl + run: | + DISPLAY=:0 xvfb-run -s '-screen 0 1024x768x24' + julia --color=yes --project=docs/ docs/make.jl From 3faa15d559c861db12aef795628a16959dd4e3a8 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 30 Sep 2025 11:22:33 +0200 Subject: [PATCH 57/61] Revert "try switching to GLMakie" This reverts commit 9b47ed51c7fb75503f394896198650c26368b01c. --- Project.toml | 6 +++--- docs/Project.toml | 2 +- docs/src/tutorials/01-basic.jl | 2 +- docs/src/tutorials/02-flow-data.jl | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Project.toml b/Project.toml index 2ca8818e..8ba1cd30 100644 --- a/Project.toml +++ b/Project.toml @@ -21,6 +21,7 @@ StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" AlgebraOfGraphics = "0.11.7" Aqua = "0.8" CSV = "^0.5.12, 0.6, 0.7, 0.8, 0.10" +CairoMakie = "0.15.6" Clustering = "0.15.8" DataFrames = "0.20, 0.21, 0.22, 1.0" Distances = "^0.8.2, 0.9, 0.10" @@ -31,7 +32,6 @@ DocStringExtensions = "0.9.5" Downloads = "1" FCSFiles = "^0.1.1, 0.2" FileIO = "^1.0.7" -GLMakie = "0.13.6" JSON = "0.21" LinearAlgebra = "1" NearestNeighbors = "^0.4.3" @@ -46,9 +46,9 @@ julia = "1" [extras] AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" -GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" @@ -56,4 +56,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [targets] -test = ["AlgebraOfGraphics", "Aqua", "GLMakie", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] +test = ["AlgebraOfGraphics", "Aqua", "CairoMakie", "Clustering", "Downloads", "JSON", "LinearAlgebra", "SHA", "Test", "XLSX"] diff --git a/docs/Project.toml b/docs/Project.toml index 475f6d08..10140e72 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,5 +1,6 @@ [deps] AlgebraOfGraphics = "cbdf2221-f076-402e-a563-3d30da359d67" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Distances = "b4f34e82-e78d-54a5-968a-f98e89d6e8f7" @@ -7,7 +8,6 @@ DistributedData = "f6a0035f-c5ac-4ad0-b410-ad102ced35df" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterTools = "35a29f4d-8980-5a13-9543-d66fff28ecb8" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" -GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" GigaSOM = "a03a9c34-069e-5582-a11c-5c984cab887c" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" diff --git a/docs/src/tutorials/01-basic.jl b/docs/src/tutorials/01-basic.jl index 54cb8ba4..3c05587c 100644 --- a/docs/src/tutorials/01-basic.jl +++ b/docs/src/tutorials/01-basic.jl @@ -99,7 +99,7 @@ e = embed(som, d) # The 2D coordinates may be plotted using any standard plotting library. In the # following example we show how to do that with AlgebraOfGraphics package: -using AlgebraOfGraphics, GLMakie, DataFrames +using AlgebraOfGraphics, CairoMakie, DataFrames embedded_data = [DataFrame(e, [:x, :y]) DataFrame(d, [:v1, :v2, :v3, :v4])] diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 612186a0..04d60069 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -189,7 +189,7 @@ som_clusters = assign(som, di) # collect the expressions and embedding into a single dataframe and plot them # as usual: -using AlgebraOfGraphics, GLMakie, DataFrames +using AlgebraOfGraphics, CairoMakie, DataFrames e = embed(som, di) From 3409220d4e6f24b604eca06c2a69e4f3574e9ce0 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Tue, 30 Sep 2025 11:22:35 +0200 Subject: [PATCH 58/61] Revert "wrap the doc building in xfvb" This reverts commit d9bd8b3add3b63879de11e182dbf5e4054560661. --- .github/workflows/docs.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a2b0d503..e0cab802 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,13 +16,9 @@ jobs: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@latest - uses: julia-actions/cache@v2 - - name: Install binary dependencies - run: sudo apt-get update && sudo apt-get install -y xorg-dev mesa-utils xvfb libgl1 freeglut3-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev - name: Install dependencies run: julia --color=yes --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy env: DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} # For authentication with SSH deploy key - run: | - DISPLAY=:0 xvfb-run -s '-screen 0 1024x768x24' - julia --color=yes --project=docs/ docs/make.jl + run: julia --color=yes --project=docs/ docs/make.jl From 5d4927a78b8ef780442d67e92f249a9ba37b9a95 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 3 Nov 2025 13:55:35 +0100 Subject: [PATCH 59/61] use the local-er mirror of PBMC dataset --- docs/src/tutorials/02-flow-data.jl | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 04d60069..02a94703 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -113,20 +113,19 @@ e = gather_array(e) ### Download and prepare the dataset # The example data can be downloaded from -# [imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/). +# [imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/). For availability reasons, we mirror them also in GigaSOM repository. # # You can fetch the files directly from within Julia: dataFiles = ["PBMC8_metadata.xlsx", "PBMC8_panel.xlsx", "PBMC8_fcs_files.zip"] for f in dataFiles if !isfile(f) - download("http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow/"*f, f) - if occursin(".zip", f) - run(`unzip PBMC8_fcs_files.zip`) - end + download("https://github.com/LCSB-BioCore/GigaSOM.jl/raw/refs/heads/mirrored-data/"*f, f) end end +run(`unzip PBMC8_fcs_files.zip`) + # The metadata is present in external files; we read it into a `DataFrame` and # extract information about FCS data columns from there. First, we read the # actual content using the XLSX package: From 3c9d78d0e9340b80b8f39b633af81f10074a8bfa Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 3 Nov 2025 13:58:14 +0100 Subject: [PATCH 60/61] fmt --- docs/src/tutorials/02-flow-data.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/tutorials/02-flow-data.jl b/docs/src/tutorials/02-flow-data.jl index 02a94703..09ab02aa 100644 --- a/docs/src/tutorials/02-flow-data.jl +++ b/docs/src/tutorials/02-flow-data.jl @@ -120,7 +120,10 @@ e = gather_array(e) dataFiles = ["PBMC8_metadata.xlsx", "PBMC8_panel.xlsx", "PBMC8_fcs_files.zip"] for f in dataFiles if !isfile(f) - download("https://github.com/LCSB-BioCore/GigaSOM.jl/raw/refs/heads/mirrored-data/"*f, f) + download( + "https://github.com/LCSB-BioCore/GigaSOM.jl/raw/refs/heads/mirrored-data/"*f, + f, + ) end end From 7ac1aa3ccdd6e25444acfad2c75d6aae72e9d412 Mon Sep 17 00:00:00 2001 From: Mirek Kratochvil Date: Mon, 3 Nov 2025 14:20:28 +0100 Subject: [PATCH 61/61] fix the code in README --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2346538f..3b62f70b 100644 --- a/README.md +++ b/README.md @@ -72,16 +72,16 @@ A comprehensive documentation is [available online](https://lcsb-biocore.github. A very basic dataset (Levine13 from [FR-FCM-ZZPH](https://flowrepository.org/id/FR-FCM-ZZPH)) can be loaded, clustered and visualized as such: ```julia -using GigaSOM +import GigaSOM -params, fcsmatrix = loadFCS("Levine_13dim.fcs") # load the FCS file +params, fcsmatrix = GigaSOM.load_fcs("Levine_13dim.fcs") # load the FCS file exprs = fcsmatrix[:,1:13] # extract only the data columns with expression values -som = initGigaSOM(exprs, 20, 20) # random initialization of the SOM codebook -som = trainGigaSOM(som, exprs) # SOM training -clusters = mapToGigaSOM(som, exprs) # extraction of per-cell cluster IDs -e = embedGigaSOM(som, exprs) # EmbedSOM projection to 2D +som = GigaSOM.init(exprs, 20, 20) # initiali ze the SOM codebook randomly +som = GigaSOM.train(som, exprs) # train the SOM over the data +clusters = GigaSOM.assign(som, exprs) # extract per-cell cluster IDs +e = GigaSOM.embed(som, exprs) # make a 2D projection with EmbedSOM ``` The example loads the data, runs the SOM training (as in FlowSOM) and computes a 2D projection of the dataset (using EmbedSOM); the total computation time (excluding the possible precompilation of the libraries) should be around 15 seconds. @@ -100,6 +100,6 @@ savePNG("Levine13-CD4.png", expressionPalette(100, alpha=0.5))))) # colors for plotting (based on RdYlBu) ``` -The output may look like this (blue is negative expresison, red is positive): +The output may look like this (blue is negative expresison of CD4, red is positive): ![Levine13 embedding with CD4 highlighted](docs/src/assets/Levine13-CD4.png "Levine13/CD4")