diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..85bef13
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,9 @@
+# .coveragerc
+
+[run]
+source = qspy
+
+[report]
+omit =
+ */tests/*
+ */site-packages/*
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..26eb18e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Change Log
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/)
+and this project adheres to [Semantic Versioning](http://semver.org/).
+
+## [0.1.0] - 2025-07-03
+
+### Added
+- Initial development version of the package, prepared for release.
+
+------
+
+## Entry format
+
+## [Unreleased] - yyyy-mm-dd
+
+N/A
+
+### Added
+
+### Changed
+
+### Fixed
\ No newline at end of file
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..0c73f4a
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,131 @@
+
+# Contributor Covenant Code of Conduct
+
+[](code_of_conduct.md)
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders 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, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[INSERT CONTACT METHOD].
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the
+[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1,
+available at
+.
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/inclusion).
+
+For answers to common questions about this code of conduct, see the FAQ at
+. Translations are available at
+.
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..a8b7e74
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,66 @@
+# Before getting started
+
+Thank you for contributing! Bug reports, feature requests, bug fixes or other contributions are all welcomed.
+
+## Coding conventions
+
+GitHub is used to host code, track issues, and accept pull requests. This project uses [semantic versioning](https://semver.org/) and keeps a [CHANGELOG](./CHANGELOG.md). Google style docstrings are used, and [Ruff formatting](https://docs.astral.sh/ruff/formatter/) is applied to code. For new functions, it is requested that type annotations be included in the function definition.
+
+
+# How to contribute
+
+
+## Issues
+
+One great non-code way to contribute is to open an issue.
+
+### Types of Issues
+
+Here are the different types of Issues you can contribute:
+
+ * :bug: **Bug Reports**: Problem, errors, or other issues where the code is not working as expected. Please use the `bug` label.
+ * :bulb: **Feauture Request/Suggestion**: Request or suggest some new functionality or an update/change to existing functionality. Please use the `enhancement` label.
+
+### Creating a new issue
+
+You can open issues here: [https://github.com/Borealis-BioModeling/qspy/issues](https://github.com/Borealis-BioModeling/qspy/issues).
+
+However, before creating a new issue, please check the existing issues first to see if your issue or a similar one has already been raised. If it has, please add a comment to the existing issue rather than creating a new duplicate issue; e.g., with a bug report commenting with something like "I am also experiencing this problem" along with any additional context about your specific environment and package versions should suffice.
+
+For all Bug Report issues, please provide context, including the environment and relevant package versions, code snippets for the offending code/use when applicable, a description of the expected outcome, and the actual outcome with associated error messages when applicable.
+
+ ### Support
+
+If you have support questions you can email them to [blakeaw1102@gmail.com](mailto:blakeaw1102@gmail.com)
+
+## Pull Requests
+
+If you want to contribute code that fixes bugs or adds new features you can fork the repository and open a pull request as described below.
+
+However, before doing so, please ask first. You can do so by commenting on the relevant Issue. This helps prevent duplicated or wasted efforts.
+
+Note that for automated testing and coverage analysis requires the following:
+[pytest](https://docs.pytest.org/en/stable/getting-started.html), [Coverage.py](https://coverage.readthedocs.io/en/7.6.10/install.html), and [nose](https://nose.readthedocs.io/en/latest/).
+```
+pip install pytest coverage nose
+```
+
+PR contribution steps:
+
+1. [Fork the repo](https://github.com/Borealis-BioModeling/qspy/fork)
+2. Create a new branch, e.g.:
+ * **Bug Fix:** `fix/issue-number`, e.g. `fix/11`
+ * **New Feature** `feature/new-feature`, e.g. `feature/foo-bar`
+3. For feature additions, please include additional tests for the new feature.
+4. Run all the tests using pytest: `python -m pytest` - or also with coverage (Coverage.py) analysis: `coverage run -m pytest`
+5. Once your branch passes all the tests, commit your changes, e.g. `git commit -am 'Add the new-feature feature.'`
+7. Push the branch to your fork, e.g. `git push origin feature/new-feature`
+8. Create a new [Pull request](https://github.com/Borealis-BioModeling/qspy/pulls). Reference any relevant Issues in the PR description.
+
+Please note that any code contributions will be licensed according to this project's [LICENSE](./LICENSE).
+
+## Code of Conduct
+
+All contributions will be considered based solely on their quality and fit with the overall direction of the project.
+
+All contributors are expected to be kind and respectful to one another. Behavior that is harmful to your fellow contributors is not acceptable.
\ No newline at end of file
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 0000000..93776b0
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,15 @@
+# QSPy Contributors
+
+Thank you to everyone who has contributed to the success of this project!
+
+## Core Contributors
+
+* **Blake A. Wilson** (@blakeaw) - Lead Developer, Project Maintainer
+
+## Valued Contributors
+
+* **Your name could be here** (@contributor) - Bug fixes, Documentation improvements, etc.
+
+## Special Thanks
+
+* **PySB Developers and Contributors** - QSPy is built on top of [PySB](https://pysb.org/), so wouldn't be possible without the hard work of PySB developers and contributors!
\ No newline at end of file
diff --git a/README.md b/README.md
index 9ff1ef5..c91c399 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,189 @@
# QSPy: Quantitative Systems Pharmacology in Python
+
+
QSPy ('Cue Ess Pie') is a Python-based framework for the programmatic construction of rule-based mathematical models that describe drugs and their interactions with biological systems. Built on [PySB](https://pysb.org/), it enables modular modeling and simulation of quantitative systems pharmacology (QSP) models.
+
+[](https://www.repostatus.org/#wip)
+
+[](https://pysb.org/)
+[](LICENSE)
+[](https://github.com/Borealis-BioModeling/qspy/releases)
+[](https://github.com/astral-sh/ruff)
+
+[](https://github.com/Borealis-BioModeling/qspy/actions/workflows/ruff.yml)
+
+ :pill: :computer:
+
+## What's new in
+
+**version 0.1.0**
+
+ * First release!
+
+## Table of Contents
+
+ 1. [Install](#install)
+ 1. [Dependencies](#dependencies)
+ 2. [pip install](#pip-install)
+ 3. [Manual install](#manual-install)
+ 2. [License](#license)
+ 3. [Change Log](#change-log)
+ 4. [Documentation and Usage](#documentation-and-usage)
+ 1. [Quick Overview](#quick-overview)
+ 2. [Example](#example)
+ 5. [Contact](#contact)
+ 6. [Contributing](#contributing)
+ 7. [Supporting](#supporting)
+ 8. [Other Useful Tools](#other-useful-tools)
+
+------
+
+# Install
+
+| **! Note** |
+| :--- |
+| qspy is still in version zero development so new versions may not be backwards compatible. |
+
+**qspy** has been developed with Python 3.11.9 and PySB 1.15.0.
+
+## Dependencies
+
+`QSPy` has the following core dependencies:
+
+ * [PySB](https://pysb.org/)
+ * [pysb-pkpd](https://blakeaw.github.io/pysb-pkpd/)
+ * [pysb-units](https://github.com/Borealis-BioModeling/pysb-units)
+ * [Microbench](https://github.com/alubbock/microbench)
+ * [PyViPR](https://pyvipr.readthedocs.io/en/latest/)
+ * [MerGram](https://github.com/blakeaw/mergram)
+
+## Installation
+ 1. Install **PySB** using [conda](https://docs.conda.io/en/latest/) or [mamba](https://github.com/mamba-org/mamba):
+ ```sh
+ conda install -c alubbock pysb
+ ```
+ **OR**
+ ```sh
+ mamba install -c alubbock pysb
+ ```
+ 2. Install **qspy** with pip:
+ ```sh
+ pip install cueesspie
+ ```
+
+### Testing and Coverage
+
+For automated testing and coverage analysis:
+ * [pytest](https://docs.pytest.org/en/stable/getting-started.html)
+ * [Coverage.py](https://coverage.readthedocs.io/en/7.6.10/install.html)
+ * [nose](https://nose.readthedocs.io/en/latest/)
+```
+pip install pytest coverage nose
+```
+
+------
+
+# License
+
+This project is licensed under the BSD 2-Clause License - see the [LICENSE](LICENSE) file for details
+
+------
+
+# Change Log
+
+See: [CHANGELOG](CHANGELOG.md)
+
+------
+
+# Documentation and Usage
+
+Full documentation is available at:
+
+[Placeholder](https://blakeaw.github.io/qspy/)
+
+Built With:
+
+[](https://squidfunk.github.io/mkdocs-material/)
+
+### Quick Start Example
+
+```python
+from qspy import Model, parameters, monomers, rules, initials, observables
+from qspy.functionaltags import PROTEIN, DRUG
+from qspy.validation import ModelMetadataTracker, ModelChecker
+
+Model(name="SimpleQSP").with_units(concentration='nM', time='1/s', volume='L')
+
+with parameters():
+ k_f = (1.0, "1/min")
+ k_r = (0.5, "1/min")
+ L_0 = (100.0, "nM")
+ R_0 = (10.0, "nM")
+
+with monomers():
+ L = (["b"], {}, DRUG.AGONIST)
+ R = (["b", 'active'], {'active':[False, True]}, PROTEIN.RECEPTOR)
+
+with rules():
+ bind = (L(b=None) + R(b=None, active=False) | L(b=1) % R(b=1, active=True), k_f, k_r)
+
+with initials():
+ L(b=None) << L_0
+ R(b=None, active=False) << R_0
+
+with observables():
+ L() > "L_total"
+ R() > "R_total"
+ R(active=True) > "R_active"
+
+# Track and export model metadata
+ModelMetadataTracker(version="1.0.0", author="Alice", export_toml=True)
+
+# Run model validation checks
+ModelChecker()
+
+# Generate a Markdown summary of the model
+model.markdown_summary()
+
+```
+
+------
+
+# Contact
+
+ * **Issues** :bug: : Please open a [GitHub Issue](https://github.com/Borealis-BioModeling/qspy/issues) to
+report any problems/bugs with the code or its execution, or to make any feature requests.
+ * **Discussions** :grey_question: : If you have questions, suggestions, or want to discuss anything else related to the project, feel free to use the [Discussions](https://github.com/Borealis-BioModeling/qspy/discussions) board.
+* **Support** :question: : For any other support inquiries you can send an email to [blakeaw1102@gmail.com](mailto:blakeaw1102@gmail.com).
+
+------
+
+# Contributing
+
+Interested in contributing to this project? See [CONTRIBUTING](./CONTRIBUTING.md) for details.
+
+------
+
+# Supporting
+
+I'm very happy that you've chosen to use __qspy__. This add-on is a project that I develop and maintain on my own time, independently of the core PySB library, and without external funding. If you've found it helpful, here are a few ways you can support its ongoing development:
+
+* **Star** :star: : Show your support by starring the [GitHub repository](https://github.com/Borealis-BioModeling/qspy). It helps increase the project's visibility and lets others know it's useful. It also benefits my motivation to continue improving the package!
+* **Share** :mega: : Sharing `qspy` on your social media, forums, or with your network is another great way to support the project. It helps more people discover `qspy`, which in turn motivates me to keep developing!
+* **Cite** :books: : Citing or mentioning this software in your work, publications, or projects is another valuable way to support it. It helps spread the word and acknowledges the effort put into its development, which is greatly appreciated!
+* **Sponsor** :dollar: : Even small financial contributions, such as spotting me the cost of a tea through Ko-fi so I can get my caffeine fix, can make a big difference! Every little bit can help me continue developing this and other open-source projects.
+
+[](https://ko-fi.com/J3J4ZUCVU)
+
+-----
+
+# Acknowledegments
+
+Special thanks for [Martin Breuss's MkDocs tuorial](https://realpython.com/python-project-documentation-with-mkdocs/#step-2-create-the-sample-python-package), which served as the template for setting up and generating documentation using Mkdocs.
+
+**AI Acknowledgement**
+
+This package was developed with AI assistance. This inlcudes the generative AI tools ChatGPT, Microsoft Copilot, and GitHub Copilot, which were used to brainstorm features and implementation details, draft initial code snippets and boilerplate, and support documentation through outlining, editing, and docstring generation.
+
+-----
\ No newline at end of file
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..1f6faf1
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,8 @@
+# Support
+
+Feel to reach out through the following channels if you need help with something:
+
+- **Issues** :bug: : Please open a [GitHub Issue](https://github.com/Borealis-BioModeling/qspy/issues) to
+ report any problems/bugs with the code or its execution, or to make any feature requests.
+- **Discussions** :grey_question: : If you have questions, suggestions, or want to discuss anything else related to the project, feel free to use the [QSPy Discussions](https://github.com/Borealis-BioModeling/qspy/discussions) board.
+- **Support** :question: : For any other support inquiries you can reach out in the Support room of our Gitter chat: [](https://app.gitter.im/#/room/#qspy:gitter.im).
\ No newline at end of file
diff --git a/assets/qspy-logo.png b/assets/qspy-logo.png
new file mode 100644
index 0000000..54fd9c1
Binary files /dev/null and b/assets/qspy-logo.png differ
diff --git a/docs/about-qspy.md b/docs/about-qspy.md
new file mode 100644
index 0000000..21febcc
--- /dev/null
+++ b/docs/about-qspy.md
@@ -0,0 +1,53 @@
+# About QSPy
+
+## Why `QSPy`?
+
+- **Programmatic Modeling** – Enables automated workflows, reproducibility (_e.g., version control and automated testing_), customization, and creation of reusable functions for pharmacological and biochemical processes.
+- **Built-in Support for Mechanistic Modeling** – QSPy is built on [PySB](https://pysb.org/)'s mechanistic modeling framework, allowing you to incorporate biochemical mechanisms and build customized mechanistic PK/PD and QSP/QST models.
+- **Rule-Based Approach** – Encode complex pharmacological and biochemical processes using intuitive [rule-based modeling](https://en.wikipedia.org/wiki/Rule-based_modeling). No need to enumerate all reactions/molecular species or manually encode the corresponding network of differential equations.
+- **Python-Based** – Seamlessly integrates with Python’s scientific computing ecosystem, supporting advanced simulations, data analysis, and visualization.
+- **Arbitrary Number of Compartments** – Specify any number of compartments to build custom multi-compartment models, including complex drug distribution and physiologically-based pharmacokinetic (PBPK) models.
+- **Enhanced reproducibility and reporting** - With built in tools like the `ModelChecker` and `ModelMetadataTracker`, you can automatically catch potential issues with model structure or components early while also tracking relevant metadata for downstream reproduction and reporting.
+- **Open-Source** - QSPy is free and open-source, meaning it is freely available and fully customizable.
+
+------
+
+## Key Features
+
+- **Contextualized model definition** - QSPy introduces a block-based extension of the PySB domain-specific language (DSL) that organizes model components (monomers, parameters, rules, etc.) into named contexts, more closely mimicking the feel of traditional, block-based, DSLs like [BioNetGen](https://bionetgen.org/), [rxode2](https://nlmixr2.github.io/rxode2/), and [mrgsolve](https://mrgsolve.org/). This structure streamlines model definition and improves readability, while remaining fully interoperable with standard class-based definitions and preserving the full flexibility of PySB’s Python-embedded framework.
+
+| PySB components | QSPy contexts |
+| ---- | ------------- |
+| | |
+
+- **Native support for units** - In QSPy, models and their parameters can be assigned physical units (e.g., `mg`, `nM`, `hr⁻¹`, `L/min`), enabling automatic conversions, dimensional analysis, and consistency checking.
+
+- **Initial model validation tools** - QSPy provides a `ModelChecker` utility that automatically identifies unused components, zero-valued parameters, missing initial conditions, and overdefined reactions. Warnings are surfaced in real time during model import, with structured logs exported for reproducibility and review.
+
+- **Metadata tracking** - QSPy includes a `ModelMetadataTracker` object that attaches key information, such as author, model version, Python environment, and package versions, directly to the model. This metadata can be exported to a `.toml` file that's both human- and machine-readable, making it easy to track provenance and support downstream reporting or validation workflows.
+
+- **Built-in logging** - Model construction steps, metadata, and redacted provenance are logged to `.qspy/` folders, giving you a reproducible and inspectable trail for every model version.
+
+- **Functional monomer tagging** - QSPy introduces structured tags for classifying monomer components by biological role or modeling intent: e.g., `PROTEIN.RECEPTOR` and `DRUG.INHIBITOR`. These tags add additional expressiveness to model species and enable an additional way to filter monomer components for searches and analyses.
+```python
+# Using @ operator
+Monomer('Drug', ['b']) @ DRUG.INHIBITOR
+
+# Using @= operator
+Monomer('Target', ['b'])
+Target @= PROTEIN.RECEPTOR
+
+# Inside monomers context
+with monomers():
+ Decoy = (['b'], None, PROTEIN.RECEPTOR)
+```
+
+------
+
+## Acknowledegments
+
+Special thanks for [Martin Breuss's MkDocs tuorial](https://realpython.com/python-project-documentation-with-mkdocs/#step-2-create-the-sample-python-package), which served as the template for setting up and generating documentation using Mkdocs.
+
+**AI Acknowledgement**
+
+Generative AI tools, including ChatGPT, Microsoft Copilot, and GitHub Copilot, were used to brainstorm features and implementation details, draft initial code snippets and boilerplate, and support documentation through outlining, editing, and docstring generation.
\ No newline at end of file
diff --git a/docs/assets/navig-8_hey-listen.svg b/docs/assets/navig-8_hey-listen.svg
new file mode 100644
index 0000000..5935133
--- /dev/null
+++ b/docs/assets/navig-8_hey-listen.svg
@@ -0,0 +1,386 @@
+
+
+
+
diff --git a/docs/assets/qspy-logo-plain.svg b/docs/assets/qspy-logo-plain.svg
new file mode 100644
index 0000000..d570b90
--- /dev/null
+++ b/docs/assets/qspy-logo-plain.svg
@@ -0,0 +1,122 @@
+
+
+
+
diff --git a/docs/assets/qspy-logo.png b/docs/assets/qspy-logo.png
new file mode 100644
index 0000000..54fd9c1
Binary files /dev/null and b/docs/assets/qspy-logo.png differ
diff --git a/docs/citing.md b/docs/citing.md
new file mode 100644
index 0000000..ae27fb1
--- /dev/null
+++ b/docs/citing.md
@@ -0,0 +1,18 @@
+If you use `QSPy` in your research, publications, or projects, we kindly ask that you cite it!
+
+**APA:**
+
+Blake, W., (2025). QSPy (Version 0.1.0) [Computer software]. https://github.com/Borealis-BioModeling/qspy
+
+**BibTex:**
+
+```bibtex
+@software{BlakeW_qspy-2025,
+author = {Blake, Wilson},
+license = {BSD-2-Clause},
+title = {{QSPy}},
+url = {https://github.com/Borealis-BioModeling/qspy},
+version = {0.1.0},
+year = {2025}
+}
+```
diff --git a/docs/contact-support.md b/docs/contact-support.md
new file mode 100644
index 0000000..c3ed428
--- /dev/null
+++ b/docs/contact-support.md
@@ -0,0 +1,8 @@
+# Contact & Support
+
+Feel to reach out through the following channels:
+
+- **Issues** :bug: : Please open a [GitHub Issue](https://github.com/Borealis-BioModeling/qspy/issues) to
+ report any problems/bugs with the code or its execution, or to make any feature requests.
+- **Discussions** :grey_question: : If you have questions, suggestions, or want to discuss anything else related to the project, feel free to use the [QSPy Discussions](https://github.com/Borealis-BioModeling/qspy/discussions) board.
+- **Support** :question: : For any other support inquiries you can reach out in the Support room of our Gitter chat: [](https://app.gitter.im/#/room/#qspy:gitter.im).
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..2bb8237
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,26 @@
+# Contributing
+
+Development is centered around the [qspy GitHub project page](https://github.com/Borealis-BioModeling/qspy).
+
+## Getting Involved
+
+Here’s are a couple of non-code ways you can get involved:
+
+ * **Issues** :bug: : Please open a [GitHub Issue](https://github.com/Borealis-BioModeling/qspy/issues) to
+report any problems/bugs with the code or its execution, or to make any feature requests.
+ * **Discussions** :grey_question: : If you have questions, suggestions, or want to discuss anything else related to the project, feel free to use the [Discussions](https://github.com/Borealis-BioModeling/qspy/discussions) board.
+
+## Contributing Code
+
+Contributions are welcomed! If you’d like to improve `qspy`, see the [Contributing Guide](https://github.com/Borealis-BioModeling/qspy/blob/main/CONTRIBUTING.md).
+
+
+## Sharing Models
+
+Developed a model using `qspy`? Feel free to share it with us in [this GitHub Discussion](https://github.com/Borealis-BioModeling/qspy/discussions/2)!
+
+## Code of Conduct
+
+All contributions will be considered based solely on their quality and fit with the overall direction of the project.
+
+All contributors are expected to be kind and respectful to one another. Behavior that is harmful to your fellow contributors is not acceptable.
\ No newline at end of file
diff --git a/docs/experimental.md b/docs/experimental.md
new file mode 100644
index 0000000..a4a5af9
--- /dev/null
+++ b/docs/experimental.md
@@ -0,0 +1,111 @@
+# Experimental Features in QSPy
+
+The `qspy.experimental` module provides early-access APIs and advanced modeling features that are not yet part of the stable QSPy release. These features are intended for prototyping and feedback. Please try them out and let us know what you think!
+
+!!! warning
+ Experimental features may not be stable or properly tested. They may also change dramatically or be removed in future versions.
+
+---
+
+## Overview
+
+Experimental features in QSPy include:
+
+- **Functional Monomers:** Classes, mixins, protein-specific monomers, and advanced macros for building and manipulating functional monomers.
+- **Infix Macros:** Expressive infix-style macros for model specification, enabling readable code for binding, elimination, and equilibrium reactions.
+
+---
+
+## Functional Monomers
+
+Located in `qspy.experimental.functional_monomers`, this subpackage provides:
+
+- `FunctionalMonomer`: Base class for monomers with functional tags and base states.
+- Mixins for binding (`BindMixin`), synthesis (`SynthesizeMixin`), and degradation (`DegradeMixin`).
+
+### Protein-Specific Classes
+
+- `Ligand`: Class for ligand monomers with binding functionality.
+- `Receptor`: Class for receptor monomers with orthosteric/allosteric binding and activation logic.
+
+**Example:**
+```python
+from qspy.experimental.functional_monomers.protein import Ligand, Receptor
+
+lig = Ligand("LigandA")
+rec = Receptor("ReceptorA")
+
+# Define binding and turnover reactions
+lig.binds_to(rec, "b_ortho", k_f, k_r)
+rec.turnover(k_syn, k_deg)
+```
+
+### Advanced Macros
+
+- `activate_concerted`: For concerted activation of a receptor by a ligand, combining binding and state change in one step.
+
+**Example:**
+```python
+from qspy.experimental.functional_monomers.macros import activate_concerted
+
+activate_concerted(
+ ligand, "b", receptor, "b",
+ {"state": "inactive"}, {"state": "active"},
+ [k_f, k_r]
+)
+```
+
+---
+
+## Infix Macros
+
+Located in `qspy.experimental.infix_macros`, these macros allow for expressive, readable model code using infix-like chemical/biological/pharmcological operators:
+
+- `*binds*`: For reversible binding reactions.
+- `*eliminated*`: For elimination reactions.
+- `*equilibrates*`: For reversible state transitions.
+
+**Example:**
+```python
+from qspy.experimental.infix_macros import binds, eliminated, equilibrates
+
+# Reversible binding
+species1 *binds* species2 @ ('binding_site1', 'binding_site2') & (k_f, k_r)
+## OR
+species1(binding_site1=None) *binds* species2(binding_site2=None) & (k_f, k_r)
+
+# Elimination
+species *eliminated* compartment & k_elim
+
+# State equilibrium
+state1 *equilibrates* state2 & (k_f, k_r)
+```
+
+---
+
+## Usage Notes
+
+- **API Stability:** Experimental features may change without notice. Use with caution in production models.
+- **Feedback:** User feedback is welcome! Please report issues or suggestions to the QSPy development team.
+- **Documentation:** Experimental features may have limited documentation. Refer to source code and docstrings for details.
+
+---
+
+## How to Import
+
+Experimental features are not imported by default. You must explicitly import them from the `qspy.experimental` namespace:
+
+```python
+from qspy.experimental.infix_macros import binds, eliminated
+from qspy.experimental.functional_monomers.protein import Ligand, Receptor
+```
+
+---
+
+## Contributing
+
+We want to know how useful these experimental features are, and how well they work with your modeling workflows. So, please try them out and let us know what you think through any of our [contact/support channels](./contact-support.md), and you can report any functional issues using the GitHub Issue tracker.
+
+If you have ideas for new experimental features, please reach out to share your ideas, or see our [Contributing Guidelines](./contributing.md) if you want to contribute code.
+
+---
diff --git a/docs/functional-tags.md b/docs/functional-tags.md
new file mode 100644
index 0000000..e58de0f
--- /dev/null
+++ b/docs/functional-tags.md
@@ -0,0 +1,75 @@
+# Functional Tags for Monomers in QSPy
+
+In QSPy, functional tags annotate **monomers** with biologically or computationally meaningful labels. These tags express a **class/function** relationship—such as marking a species as a `receptor`, adding additional expressiveness to model species while also supporting another way to filter monomers.
+
+---
+
+## Tagging Semantics
+
+- Tags indicate **class** and **function/subclass** roles.
+- Each monomer can have a **single tag**.
+- Tags improve:
+ - Model expressiveness
+ - Filtering by biological function
+
+Functional tags are in all caps and have the following pattern in model definitions:
+
+ CLASS.FUNCTION
+
+### Python Example
+
+```python
+ with monomers():
+ drug = (['b'], None, DRUG.INHIBITOR)
+```
+
+Functional tags are accesible by a monomer's `functional_tag` attribute:
+
+```python
+>>> print(drug.functional_tag)
+```
+
+ FunctionalTag(class_='drug', function='inhibitor')
+
+---
+
+## Recognized Classes and Functions
+
+The table below lists standardized tags currently used in QSPy.
+
+| **Class Tag** | **Function/Subclass** | **Description** |
+| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
+| `DRUG` | `SMALL_MOLECULE`, `BIOLOGIC`, `ANTIBODY`, `MAB`, `INHIBITOR`, `AGONIST`, `ANTAGONIST`, `INVERSE_AGONIST`, `MODULATOR`, `ADC`, `RLT`, `PROTAC`, `IMMUNOTHERAPY`, `CHEMOTHERAPY` | Therapeutic agents |
+| `PROTEIN` | `LIGAND`, `RECEPTOR`, `RECEPTOR_DECOY`, `KINASE`, `PHOSPHATASE`, `ADAPTOR`, `TRANSCRIPTION_FACTOR`, `ENZYME`, `ANTIBODY` | Biologically active protein-based macromolecules |
+| `RNA` | `MESSENGER`, `MICRO`, `SMALL_INTERFERING`, `LONG_NONCODING` | Transcribed nucleic acids involved in gene expression, regulation, or signal propagation |
+| `METABOLITE` | `SUBSTRATE`, `PRODUCT`, `COFACTOR` | Small molecules involved in biochemical reactions, such as intermediates, reactants, and regulatory modulators |
+| `LIPID` | `EICOSANOID`, `PHOSPHOLIPID`, `GLYCOLIPID`, `STEROL` | Hydrophobic or amphipathic molecules involved in signaling or other biological functions |
+| `ION` | `CALCIUM`, `CHLORIDE`, `MAGNESIUM`, `SODIUM`, `POTASSIUM` | Charged atoms or small molecules involved in electrochemical signaling, osmotic balance, and catalysis |
+| `NANOPARTICLE` | `DRUG_DELIVERY`, `IMAGING`, `SENSING`, `THERMAL`, `THERANOSTIC` | Engineered nanoscale particles designed for targeted delivery, imaging enhancement, or localized therapeutic functions |
+
+## Custom tags
+
+Users can define custom tags. For consistency, follow the schema above when possible.
+
+Functional tags are subclasses of the `enum.Enum` object and can be defined as below:
+
+```python
+from enum import Enum
+from qspy.functionaltags import prefixer
+
+MY_TAG_PREFIX = "mytag"
+class MY_TAG(Enum):
+ FUNCTION = prefixer("function", MY_TAG_PREFIX)
+ SUBCLASS = prefixer("subclass", MY_TAG_PREFIX)
+```
+
+Then, you can use the custom tag like any other:
+
+```python
+with monomers():
+ A = (None, None, MY_TAG.FUNCTION)
+
+Monomer('B') @ MY_TAG.SUBCLASS
+```
+
+---
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..e0df0e7
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,80 @@
+# Getting Started with QSPy
+
+## Installation
+
+### Dependencies
+
+`QSPy` has the following core dependencies:
+
+ * [PySB](https://pysb.org/)
+ * [pysb-pkpd](https://blakeaw.github.io/pysb-pkpd/)
+ * [pysb-units](https://github.com/Borealis-BioModeling/pysb-units)
+ * [Microbench](https://github.com/alubbock/microbench)
+ * [PyViPR](https://pyvipr.readthedocs.io/en/latest/)
+ * [MerGram](https://github.com/blakeaw/mergram)
+
+### Installation steps
+
+ 1. Install **PySB** using [conda](https://docs.conda.io/en/latest/) or [mamba](https://github.com/mamba-org/mamba):
+
+```sh
+conda install -c alubbock pysb
+```
+
+**OR**
+
+```sh
+mamba install -c alubbock pysb
+```
+
+ 2. Install **qspy** with pip:
+
+```sh
+pip install cueesspie
+```
+
+Ensure you have Python 3.11.3+ and PySB 1.15.0+ installed.
+
+## Quick-start Example
+
+```python
+from qspy import *
+from qspy.functionaltags import PROTEIN, DRUG
+from qspy.validation import ModelMetadataTracker, ModelChecker
+
+Model(name="SimpleQSP").with_units(concentration='nM', time='1/s', volume='L')
+
+with parameters():
+ k_f = (1.0, "1/min")
+ k_r = (0.5, "1/min")
+ L_0 = (100.0, "nM")
+ R_0 = (10.0, "nM")
+
+with monomers():
+ L = (["b"], {}, DRUG.AGONIST)
+ R = (["b", 'active'], {'active':[False, True]}, PROTEIN.RECEPTOR)
+
+with rules():
+ bind = (L(b=None) + R(b=None, active=False) | L(b=1) % R(b=1, active=True),
+ k_f, k_r)
+
+with initials():
+ L(b=None) << L_0
+ R(b=None, active=False) << R_0
+
+with observables():
+ L() > "L_total"
+ R() > "R_total"
+ R(active=True) > "R_active"
+
+# Track and export model metadata
+ModelMetadataTracker(version="1.0.0", author="Alice", export_toml=True)
+
+# Run model validation checks
+ModelChecker()
+
+if __name___ == "__main__"
+ # Generate a Markdown summary
+ model.markdown_summary()
+
+```
\ No newline at end of file
diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md
new file mode 100644
index 0000000..c29e9ee
--- /dev/null
+++ b/docs/how-to-guides.md
@@ -0,0 +1,47 @@
+# How-To Guides
+
+## How to Simulate a Model
+
+`qspy` provides a `simulate` function that can be used to easily
+execute a dynamic ODE-based simulation of your QSP model as below:
+
+```python
+import numpy as np
+from qspy import simulate
+from my_qsp_model import model
+
+# Simulate the QSP/PKPD/PySB model.
+## Set the timespan for the simulation:
+tspan = np.arange(241) # 0-240 seconds at 1 second intervals
+## Execute the simulation:
+simulation_trajectory = simulate(model, tspan)
+```
+
+## How to filter a model's monomers by functional tag
+
+```python
+from qspy.functionaltags import *
+from my_qsp_model import model
+
+# Get all the monomers tagged as protein receptors
+receptors = model.monomers.filter(lambda m: m.functional_tag == PROTEIN.RECEPTOR)
+# Get all the monomers tagged as inhibitor drugs
+inhibitors = model.monomers.filter(lambda m: m.functional_tag == DRUG.INHIBITOR)
+```
+
+## How to define custom monomer functional tags
+
+See [Functional Tags: Custom Tags](./functional-tags.md#custom-tags)
+
+## 🚧 Page Still Under Development 🚧
+
+Thank you for your interest in our **How-To Guides** section! We’re actively working on expanding these pages to provide **step-by-step instructions** and **hands-on examples** for using `qspy`.
+
+Our goal is to make these resources **clear, practical, and easy to follow**—but we’re still in the process of gathering content and refining details.
+
+Stay tuned! In the meantime:
+
+- **Have a specific question?** Feel free to explore our existing documentation or reach out to the community.
+- **Want to contribute?** If you have suggestions or example workflows, we'd love to hear from you!
+
+Check back soon for updates as we continue to improve these guides!
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..d842def
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,31 @@
+---
+icon: material/home
+---
+
+# QSPy: Quantitative Systems Pharmacology in Python
+
+{ width="200" }
+
+
+`QSPy` (pronounced _"Cue Ess Pie"_) is a [Python](https://www.python.org/) framework for building modular, rule-based models that describe drug behavior and pharmacological interactions within biological systems. Leveraging the power of [PySB](https://pysb.org/), it streamlines the development, simulation, and analysis of **quantitative systems pharmacology (QSP)** models through a reproducible and programmatic approach.
+
+------
+
+[ Getting Started ](./getting-started.md){ .md-button .md-button--primary } [How-To Guides](./how-to-guides.md){ .md-button .md-button--primary } [Build Model](./model-specification.md){ .md-button .md-button--primary } [Share Model](https://github.com/Borealis-BioModeling/qspy/discussions/2){ .md-button .md-button--primary }
+
+[API Documentation](./reference.md){ .md-button .md-button--primary } [ Need Help? ](./contact-support.md){ .md-button .md-button--primary } [About QSPy](./about-qspy.md){ .md-button .md-button--primary } [Contributing](./contributing.md){ .md-button .md-button--primary }
+
+------
+
+------
+
+
+{ width="150" }
+!!! quote "Navig-8 says:"
+ Thanks for exploring QSPy! This project is still in early development, so your feedback and support are especially important. You can help us continue to make QSPy better!
+
+[Leave Feedback](https://app.gitter.im/#/room/#qspy-feedback:gitter.im){ .md-button .md-button--primary } [Other Ways to Support the Project](./supporting.md){ .md-button .md-button--primary }
+
+------
+
+[](https://pysb.org/)
\ No newline at end of file
diff --git a/docs/license.md b/docs/license.md
new file mode 100644
index 0000000..19221cb
--- /dev/null
+++ b/docs/license.md
@@ -0,0 +1,24 @@
+# BSD 2-Clause License
+
+**Copyright (c) 2025, Borealis BioModeling (Blake A. Wilson)**
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/docs/metadata-tracking.md b/docs/metadata-tracking.md
new file mode 100644
index 0000000..d29e10a
--- /dev/null
+++ b/docs/metadata-tracking.md
@@ -0,0 +1,91 @@
+# Model Metadata Tracking in QSPy
+
+QSPy provides robust metadata tracking for all models using the `ModelMetadataTracker` class. This enables reproducibility, provenance, and environment capture for quantitative systems pharmacology (QSP) workflows.
+
+---
+
+## What is `ModelMetadataTracker`?
+
+`ModelMetadataTracker` is a utility class that captures and manages metadata about your QSPy model, including:
+
+- Model version, author, and creation timestamp
+- The current user and environment details (Python, OS, package versions)
+- A hash of the model's rules and parameters (for provenance)
+- Optional export to TOML for archiving and sharing
+
+Metadata is automatically attached to the model instance and can be exported or loaded as needed.
+
+---
+
+## Typical Usage
+
+### 1. Automatic Tracking
+
+When you instantiate `ModelMetadataTracker`, it attaches itself to the current PySB/QSPy model:
+
+```python
+from qspy.validation.metadata import ModelMetadataTracker
+
+Model().with_units(...)
+...
+...
+
+# After building your model
+ModelMetadataTracker(version="1.0", author="Alice", export_toml=True)
+```
+
+- The tracker will automatically attach itself to `model.qspy_metadata_tracker`.
+- Metadata is available as a dictionary: `model.qspy_metadata_tracker.metadata`
+- If `export_toml=True`, metadata is saved to a TOML file your configured metadata directory (`.qspy` by default).
+
+### 2. Accessing Metadata
+
+You can access the metadata dictionary at any time:
+
+```python
+meta = model.qspy_metadata_tracker.metadata
+print(meta["model_name"])
+print(meta["hash"])
+print(meta["env"]) # Environment details
+```
+
+### 3. Exporting and Loading Metadata
+
+Export metadata to a TOML file:
+
+```python
+model.qspy_metadata_tracker.export_metadata_toml() # Auto-generates filename
+model.qspy_metadata_tracker.export_metadata_toml(path="custom_metadata.toml")
+```
+
+Load metadata from a TOML file:
+
+```python
+from qspy.validation.metadata import ModelMetadataTracker
+
+meta = ModelMetadataTracker.load_metadata_toml("MyModel__Alice__abcd1234__2024-07-01.toml")
+print(meta["version"])
+```
+
+**Example**
+
+```python
+from qspy.validation.metadata import ModelMetadataTracker
+
+# Build your model here...
+
+# Track and export metadata
+tracker = ModelMetadataTracker(version="2.0.1", author="Bob", export_toml=True)
+
+# Access metadata
+print(tracker.metadata["model_name"])
+print(tracker.metadata["created_at"])
+print(tracker.metadata["env"]["platform"])
+```
+
+## Why Use Metadata Tracking?
+
+- **Reproducibility**: Know exactly which code, environment, and parameters produced your results.
+- **Provenance**: Track model changes and authorship over time.
+- **Environment Capture**: Record Python, OS, and package versions for future reference.
+- **Archival**: Export metadata alongside your model for publication or collaboration.
diff --git a/docs/model-checker.md b/docs/model-checker.md
new file mode 100644
index 0000000..09ad9f8
--- /dev/null
+++ b/docs/model-checker.md
@@ -0,0 +1,39 @@
+# `ModelChecker`: Validating Model Integrity in QSPy
+
+The `ModelChecker` is QSPy's built-in utility for diagnosing and validating model integrity before simulation. It inspects models to identify potential errors, such as unused components, zero-valued parameters, missing initial conditions and lack consistency amongst physical units. Errors and warnings are surfaced in real time during model import, with structured logs exported for reproducibility and review.
+
+---
+
+## Key Features
+
+Includes checks for:
+
+- _Unused components_: Warns of any unused monomers or parameters.
+- _Zero-valued parameters_: Warns of any parameters with a value of zero.
+- _Dangling or Re-used bonds_: Raises errors for any dangling or re-used bonds in reaction rules.
+- _Units Checks_: Warns of any duplicate, inconsistent, or missing units.
+
+---
+
+## Importing and Instantiating
+
+Inside model definition:
+
+```python
+from qspy.validation import ModelChecker
+...
+Model().with_units(...)
+...
+...
+# Runs validation checks when model is imported.
+ModelChecker()
+```
+
+Outside of model definition:
+
+```python
+from qspy.validation import ModelChecker
+from my_model import model # build programmatically
+
+checker = ModelChecker(model)
+```
diff --git a/docs/model-components.md b/docs/model-components.md
new file mode 100644
index 0000000..7655fe5
--- /dev/null
+++ b/docs/model-components.md
@@ -0,0 +1,282 @@
+# Model Components
+
+## From PySB to QSPy: Building with Context
+
+At its core, QSPy builds on [PySB (Python Systems Biology modeling)](https://pysb.org/), a Python-embedded domain specific language (DSL) for rule-based modeling of biochemical systems. PySB models are constructed from core components such as **Monomers**, **Parameters**, **Rules**, **Initials**, and **Observables**. It adopts an object-oriented approach to model building, enhanced with syntactic sugar for automatic component registration (self-exporting) and a chemistry-inspired rule syntax based on [BNGL (BioNetGen Language)](https://bionetgen.org/). QSPy preserves this foundational API while introducing an alternative, structured, context-based approach to model definition.
+
+Instead of directly initializing instances of component classes, QSPy allows grouping them into **named contexts** using Python `with` blocks.
+
+!!! example
+ ```python
+ with monomers():
+ Ligand = (["r"], None, PROTEIN.LIGAND)
+ Receptor = (["l"], None, PROTEIN.RECEPTOR)
+ ```
+
+Where applicable, QSPy then parses the inputs into the desired model components during the context exit process. As in PySB, the component names are exported into the current namespace, and the components can be programmatically manipulated.
+
+!!! example
+ ```python
+ print(Ligand)
+ ```
+
+ >>> Monomer("Ligand", ['r']) @ protein::ligand
+
+This organizational style mimics the block-based structure of classic declarative DSLs, such as [BNGL](https://bionetgen.org/) and [rxode2](https://nlmixr2.github.io/rxode2/) model specificaitons, while preserving the flexibility of a programmatic Python environment. The goal is to further streamline model encoding and improve readability by minimizing boilerplate and promoting semantic grouping of related model components.
+
+!!! note
+ QSPy contexts also incorporate logging functionality to help users track component additions and audit model assembly for reproducibility.
+
+The sections that follow describe each model component and how QSPy extends their definition.
+
+## The Model Object
+
+Every QSPy model builds upon a central `Model` object that serves as the container for all biological components, relationships, and metadata. This object is an extension of the standard PySB `Model` object, with additional hooks for logging, metadata tracking, and QSPy’s context-aware construction pattern, as well as additional utilities for setting global model units and outputting model summaries to [Markdown](https://en.wikipedia.org/wiki/Markdown) files.
+
+As with PySB, a new `Model` is typically specified in Python module file (e.g., `model.py`). When you define a `Model` in QSPy, a global `model` object is automatically available like in PySB. All subsequent context blocks, such as `with monomers()` or with `parameters()`, register their components to this object. Also, as in PySB, any model components created using their class-based objects, such as `Parameter(...)` or `Rule(...)`, are also automatically registered to the model object.
+
+```python
+Model().with_units(concentration="mg/L", time="h", volume="L")
+```
+
+As above, we recommend always chaining `Model` initialization with the `with_units` function to set global model units for concentration, time, and volume; all subsequent parameter definitions with these unit types will be automatically converted and appropriately scaled behind the scenes during model import.
+
+After model specification (e.g., in `my_model.py`), the `model` object can imported and used accordingly.
+
+```python
+from my_model import model
+```
+
+## Monomers
+
+`Monomer`s represent fundamental molecular and biological species, such as drugs, proteins, or receptors. They have _sites_ and _site states_, and can also be assigned a [functional tag](./functional-tags.md). _Sites_ may represent binding regions or other modifiable molecular features, such as phosphorylation sites with distinct states (e.g., unphosphorylated `'u'` and phosphorylated `'p'`).
+
+=== "With `monomers` context:"
+ ```python
+ with monomers():
+ Ligand = (["r"], None, DRUG.INHIBITOR)
+ Receptor = (["l"], None, PROTEIN.RECEPTOR)
+ ```
+
+=== "Context-free equivalent"
+ ```python
+ Monomer("Ligand", ['r']) @ DRUG.INHIBITOR
+ Monomer("Receptor", ['l']) @ PROTEIN.RECEPTOR
+ ```
+
+!!! note
+
+ Inside the `monomers` context the assignment pattern is:
+
+ monomer_name = (sites_list, site_states_dict, functional_tag)
+
+ Also, assigning a functional tag inside the `monomers` context is optional, so the following pattern is also valid:
+
+ ```python
+ with monomers():
+ Ligand = (["r"], None)
+ Receptor = (["l"], None)
+ ```
+
+!!! info "QSPy enhancements"
+ - Functional tagging (e.g., `PROTEIN.RECEPTOR`, `DRUG.INHIBITOR`), including new overloaded `@` operator for functional tag assignments.
+ - Contextual grouping with automatic introspection for monomer creation and naming (when using `monomers` context).
+
+## Parameters
+
+`Parameter`s quantify rate constants, concentrations, or other numeric values.
+
+=== "With `parameters` context:"
+ ```python
+ with parameters():
+ kf_bind = (1e-1, "1/uM/s")
+ kr_bind = (1e-3, "1/s")
+ Ligand_0 = (100, "nM")
+ ```
+=== "Context-free equivalent:"
+ ```python
+ Parameter("kf_bind", 1e-1, unit="1/uM/s")
+ Parameter("kr_bind", 1e-3, unit="1/s")
+ Parameter("Ligand_0" 100, unit="nM")
+ ```
+
+!!! note
+ inside the `parameters` context the assignment pattern is:
+
+ parameter_name = (value, units)
+
+!!! info "QSPy enhancements"
+ - Native support for units (e.g., mg, nM, hr⁻¹, L/min)
+ - Contextual grouping with automatic introspection for parameter creation and naming (when using `parameters` context).
+
+## Rules
+
+`Rules` define biochemical interactions such as binding or transformation.
+
+=== "With `rules` context:"
+ ```python
+ with rules():
+ bind_L_R = (Ligand(r=None) + Receptor(l=None)
+ >> Ligand(r=1) % Receptor(l=1), kf_bind, kr_bind
+ )
+ ```
+
+=== "Context-free equivalent:"
+ ```python
+ Rule("bind_L_R", Ligand(r=None) + Receptor(l=None) \
+ >> Ligand(r=1) % Receptor(l=1), kf_bind, kr_bind)
+ ```
+
+!!! note
+ inside the `rules` context the assignment pattern is:
+
+ :left_right_arrow: reversible reactions:
+
+ rule_name = (reaction pattern, forward rate consant, reverse rate constant)
+
+
+ :arrow_right: irreversible reactions:
+
+ rule_name = (reaction pattern, rate constant)
+
+!!! info "QSPy enhancements"
+ - Contextual grouping with automatic introspection for rule creation and naming (when using `rules` context).
+
+## Initial Conditions
+
+`Initial`s specify the starting concentrations or states of species in the model.
+
+=== "With `initials` context:"
+ ```python
+ with initials():
+ Ligand(r=None) << Ligand_0
+ ```
+
+=== "Context-free equivalent:"
+ ```python
+ Ligand(r=None) << Ligand_0
+ ```
+=== "Context-free without the `<<` operator:"
+ ```python
+ Initial(Ligand(r=None), Ligand_0)
+ ```
+
+!!! info "QSPy enhancements"
+ - Optional grouped `initials` context for clearer organization and additional logging
+ - New overloaded `<<` operator for initial condition assignment without the need to explicitly initialize an `Initial` object.
+
+## Observables
+
+`Observable`s define measurable quantities derived from model states.
+
+=== "With `observables` context:"
+ ```python
+ with observables():
+ Ligand(r=1) % Receptor(l=1) > "BoundComplex"
+ ```
+=== "auto naming using `~` prefix operator:"
+ ```python
+ with observables():
+ ~Ligand(r=1) % Receptor(l=1)
+ ```
+=== "Context-free equivalent with `>` operator:"
+ ```python
+ Ligand(r=1) % Receptor(l=1) > "BoundComplex"
+ ```
+=== "Context-free equivalent with `~` operator:"
+ ```python
+ ~Ligand(r=1) % Receptor(l=1)
+ ```
+=== "Context-free without the `>` or `~` operators:"
+ ```python
+ Observable("BoundComplex", Ligand(r=1) % Receptor(l=1))
+ ```
+
+!!! info "QSPy enhancements"
+ - Optional grouped `observables` context for clearer organization and additional logging
+ - New overloaded `>` operator for observable assignment without the need to explicitly initialize an `Observable` object.
+ - New overloaded `~` operator for observable assignment with an auto-generated name, and without the need to explicitly initialize an `Observable` object.
+
+## Expressions
+
+Expressions define algebraic relationships between parameters, observables, or other expressions. They’re useful for computing composite values like dose scaling factors, compartment-adjusted concentrations, or feedback-modulated rates.
+
+=== "With `expressions` context:"
+ ```python
+ with expressions():
+ K_d = k_r / k_f # dissociation constant
+ ```
+=== "Context-free equivalent:"
+ ```python
+ Expression("K_d", k_r / k_f) # dissociation constant
+ ```
+
+!!! info "QSPy enhancements"
+ - Contextual grouping with automatic introspection for expression creation and naming (when using `expressions` context).
+
+## Compartments
+
+`Compartment`s define spatial contexts for species and reactions, representing physical volumes or surfaces such as plasma, tissue, or organelles.
+
+=== "With contexts"
+ ```python
+ with parameters():
+ V_C = (10.0, "L")
+ V_P = (100.0, "mL")
+
+ with compartments():
+ CENTRAL = V_C
+ PERIPHERAL = V_P
+ ```
+=== "Context-free equivalent:"
+ ```python
+ Parameter("V_C", 10.0, unit="L")
+ Parameter("V_P", 100.0, unit="mL")
+
+ Compartment("CENTRAL", size=V_C)
+ Compartment("PERIPHERAL", size=V_P)
+ ```
+
+!!! info "QSPy enhancements"
+ - Contextual grouping with automatic introspection for compartment creation and naming (when using `compartments` context).
+
+## Macros
+
+Macros in QSPy provide high-level, reusable templates for common biochemical processes such as binding, catalysis, synthesis, degradation, and more. They encapsulate complex rule patterns into a single, expressive statement, improving both readability and maintainability of your model code.
+
+### Background: PySB Macros
+
+[PySB macros](https://pysb.readthedocs.io/en/stable/tutorial.html#using-provided-macros) are functions that generate sets of rules and components for common biochemical motifs (e.g., reversible binding, catalysis, synthesis, degradation). They are a foundational feature of PySB, enabling concise and readable model code for complex biological processes.
+
+QSPy builds on this foundation by:
+
+- **Incorporating all core PySB macros** (`pysb.macros`) as `qspy.macros.core` (these include functions like `bind`, `equilibrate`, `catalyze`, etc.)
+- **Including the PK/PD macros** from [`pysb-pkpd`](https://blakeaw.github.io/pysb-pkpd/macros/) (`pysb.pkpd.macros`) as `qspy.macros.pkpd` (these include PK processes such as `distribute` and `eliminate`, PD functions like `emax`, and `sigmoidal_emax`, and dosing functions like `dose_bolus`)
+- **Adding native support for units** to both sets of macros, so all rate and concentration parameters can be specified with units and are automatically converted and checked
+
+This means you can use all standard PySB macro patterns in QSPy, but with enhanced unit handling and integration with QSPy’s context and logging system.
+
+### QSPy Macro Contexts
+
+You can use macros directly or within the `macros` context for grouped, introspective macro registration. When using the `macros` context, all macro-generated components are automatically logged to the QSPy logs for auditability and reproducibility.
+
+=== "With `macros` context:"
+ ```python
+ with macros():
+ bind(Ligand(r=None), Receptor(l=None), 'r', 'l', [kf_bind, kr_bind])
+ degrade(Protein(), k_deg)
+ ```
+=== "Context-free equivalent:"
+ ```python
+ bind(Ligand(r=None), Receptor(l=None), 'r', 'l', [kf_bind, kr_bind])
+ degrade(Protein(), k_deg)
+ ```
+
+!!! info "QSPy enhancements"
+ - All macros are updated to use unit-aware model components.
+ - Contextual grouping with automatic introspection and logging when using the `macros` context.
+ - Full access to both core PySB macros and PK/PD macros from `pysb-pkpd`.
+
+Macros can greatly simplify the specification of complex reaction patterns, especially when used in combination with QSPy’s context system.
+
+For more details on available macros, see the [PySB macro documentation](https://pysb.readthedocs.io/en/stable/tutorial.html#using-provided-macros) and the [pysb-pkpd macro documentation](https://blakeaw.github.io/pysb-pkpd/macros/).
\ No newline at end of file
diff --git a/docs/model-diagram-generator.md b/docs/model-diagram-generator.md
new file mode 100644
index 0000000..31dc28d
--- /dev/null
+++ b/docs/model-diagram-generator.md
@@ -0,0 +1,83 @@
+# ModelMermaidDiagrammer: Visualizing QSPy Models
+
+The `ModelMermaidDiagrammer` object in the `qspy.utils.diagram` module provides a convenient way to generate flowchart-style diagrams of your QSPy models using [Mermaid](https://mermaid-js.github.io/mermaid/). These diagrams help you visualize the structure of your model, including compartments, species, and reactions, and can be embedded in Markdown documentation or exported to standalone files.
+
+---
+
+## What is `ModelMermaidDiagrammer`?
+
+`ModelMermaidDiagrammer` is a utility class that takes a QSPy or PySB model and produces a Mermaid diagram representing the model's components and their interactions. This is especially useful for documentation, presentations, and model review.
+
+---
+
+## Key Features
+
+- **Automatic diagram generation** from your model structure
+- **Compartment, species, and reaction visualization**
+- **Markdown and HTML export** for easy integration with documentation
+- **File export** to `.mmd` (Mermaid) format
+
+---
+
+## Usage
+
+### Basic Example
+
+```python
+# my_model.py
+from qspy.core import Model
+from qspy.utils.diagram import ModelMermaidDiagrammer
+
+# Build your model here...
+Model(name="MyModel")
+# ... define monomers, parameters, rules, etc.
+
+# Create a diagram object
+ModelMermaidDiagrammer(model)
+```
+
+then
+
+```python
+# Import your model
+from my_model import model
+
+# Get the diagram as a Markdown block
+markdown_block = model.mermaid_diagram.markdown_block
+
+# Write the diagram to a Mermaid file
+model.mermaid_diagram.write_mermaid_file("my_model_diagram.mmd")
+```
+
+### Including in Model Summary
+
+If you attach a `ModelMermaidDiagrammer` instance to your model (as `model.mermaid_diagram`), it will be automatically included in the output of `model.markdown_summary()`:
+
+---
+
+## Example Output
+
+A generated Mermaid diagram might look like:
+
+```mermaid
+graph TD
+ A -->|k_bind| C[A:B]
+ B -->|k_bind| C[A%B]
+```
+
+When rendered in Markdown or HTML, this will appear as a flowchart showing the relationships between model components.
+
+---
+
+## Why Use ModelMermaidDiagram?
+
+- **Clarity:** Visualize complex model structures at a glance.
+- **Documentation:** Embed diagrams directly in your Markdown docs or reports.
+- **Communication:** Share model architecture with collaborators and stakeholders.
+
+---
+
+## See Also
+
+- [Model Summary Generator](model-summary-generator.md)
+- [Mermaid Documentation](https://mermaid-js.github.io/mermaid/)
\ No newline at end of file
diff --git a/docs/model-specification.md b/docs/model-specification.md
new file mode 100644
index 0000000..9d3305c
--- /dev/null
+++ b/docs/model-specification.md
@@ -0,0 +1,168 @@
+# Building a Model with QSPy
+
+This guide walks through encoding a model directly in Python using QSPy’s core modules. For this purpose, we will build a relatively simple two-compartment semi-mechanistic pharmacokinetics & receptor-occupancy (PKRO) model. Typically, models are defined in their own Python module file: e.g., `pkro_model.py`.
+
+## 1) Import essential modules/objects
+
+```python
+from qspy import *
+```
+
+## 2) Create and instance of the Model class and specify global model units.
+
+```python
+Model().with_units(concentration="mg/L", time="h", volume="L")
+```
+
+## 3) Specify model parameters
+
+```python
+with parameters():
+ # drug dose
+ drug_dose = (100.0, "mg")
+ # Compartment volumes
+ V_CENTRAL = (10.0, "L")
+ V_PERIPHERAL = (1.0, "L")
+ # drug distribution rate constants
+ k_CP = (1e-1, "1/s")
+ k_PC = (1e-3, "1/s")
+ # receptor density
+ receptor_0 = (100.0, "ug/L")
+ # receptor binding rate constants
+ k_f = (1.0, "L/(ug * s)")
+ k_r = (1e-3, "1/s")
+```
+
+## 4) Define any expressions
+
+```python
+with expressions():
+ # Initial drug concentration - bolus dose
+ drug_0 = drug_dose / V_CENTRAL
+```
+
+## 5) Specify the model compartments
+
+```python
+with compartments():
+ CENTRAL = V_CENTRAL
+ PERIPHERAL = V_PERIPHERAL
+```
+
+## 6) Define any monomer species
+
+```python
+with monomers():
+ drug = (['b'], None, DRUG.AGONIST)
+ receptor = (['b'], None, PROTEIN.RECEPTOR)
+```
+
+## 7) Specify initial conditions
+
+```python
+with initials():
+ drug(b=None)**CENTRAL << drug_0
+ receptor(b=None)**PERIPHERAL << receptor_0
+```
+
+## 8) Define reaction rules
+
+```python
+with rules():
+ # Distribution
+ drug_distribution = (drug(b=None)**CENTRAL | drug(b=None)**PERIPHERAL, k_CP, k_PC)
+ # Receptor binding
+ receptor_binding = (drug(b=None)**PERIPHERAL + receptor(b=None)**PERIPHERAL | drug(b=1)**PERIPHERAL % receptor(b=1)**PERIPHERAL, k_f, k_r)
+```
+
+## 9) Assign observables
+
+```python
+with observables():
+ drug(b=1)**PERIPHERAL % receptor(b=1)**PERIPHERAL > "OccupiedReceptor"
+```
+
+## 10) Assign model metadata and tracker (optional)
+
+```python
+__version__ = 0.1.0
+__author__ = "Jane Doe"
+ModelMetadataTracker(__version__, author=__author__)
+```
+
+!!! info
+ Learn more about the [metadata tracker](./metadata-tracking.md)
+
+## 11) Initialize model checker (optional)
+
+```python
+ModelChecker()
+```
+
+!!! info
+ Learn more about the [model checker](./model-checker.md)
+
+## 12) Include a mermaid diagram generator (optional)
+
+```python
+ModelMermaidDiagrammer()
+```
+
+!!! info
+ Learn more about the [diagram generator](./model-diagram-generator.md)
+
+------
+
+## Full example model
+
+!!! example "pkro_model.py"
+
+ ```python
+ from qspy import *
+
+ with parameters():
+ # drug dose
+ drug_dose = (100.0, "mg")
+ # Compartment volumes
+ V_CENTRAL = (10.0, "L")
+ V_PERIPHERAL = (1.0, "L")
+ # drug distribution rate constants
+ k_CP = (1e-1, "1/s")
+ k_PC = (1e-3, "1/s")
+ # receptor density
+ receptor_0 = (100.0, "ug/L")
+ # receptor binding rate constants
+ k_f = (1.0, "L/(ug * s)")
+ k_r = (1e-3, "1/s")
+
+ with expressions():
+ # Initial drug concentration - bolus dose
+ drug_0 = drug_dose / V_CENTRAL
+
+ with compartments():
+ CENTRAL = V_CENTRAL
+ PERIPHERAL = V_PERIPHERAL
+
+ with monomers():
+ drug = (['b'], None, DRUG.AGONIST)
+ receptor = (['b'], None, PROTEIN.RECEPTOR)
+
+ with initials():
+ drug(b=None)**CENTRAL << drug_0
+ receptor(b=None)**PERIPHERAL << receptor_0
+
+ with rules():
+ # Distribution
+ drug_distribution = (drug(b=None)**CENTRAL | drug(b=None)**PERIPHERAL, k_CP, k_PC)
+ # Receptor binding
+ receptor_binding = (drug(b=None)**PERIPHERAL + receptor(b=None)**PERIPHERAL | drug(b=1)**PERIPHERAL % receptor(b=1)**PERIPHERAL, k_f, k_r)
+
+ with observables():
+ drug(b=1)**PERIPHERAL % receptor(b=1)**PERIPHERAL > "OccupiedReceptor"
+
+ __version__ = 0.1.0
+ __author__ = "Jane Doe"
+ ModelMetadataTracker(__version__, author=__author__)
+ ModelMermaidDiagrammer()
+ ModelChecker()
+ ```
\ No newline at end of file
diff --git a/docs/model-summary-generator.md b/docs/model-summary-generator.md
new file mode 100644
index 0000000..a2b853a
--- /dev/null
+++ b/docs/model-summary-generator.md
@@ -0,0 +1,139 @@
+# Model Summary Generator in QSPy
+
+QSPy provides a convenient method for generating human-readable summaries of your model using the `Model.markdown_summary` function. This feature helps you document, review, and share the structure and key properties of your quantitative systems pharmacology (QSP) models.
+
+---
+
+## What is `Model.markdown_summary`?
+
+The `markdown_summary` method is available on QSPy `Model` objects. It generates a Markdown summary file that includes:
+
+- Model metadata (name, version, author, timestamp, hash)
+- Monomer definitions (including sites, states, and functional tags)
+- Parameters and their units
+- Initial conditions
+- Rules
+- Observables
+- (Optionally) a model diagram if the `ModelMermaidDiagrammer` object is included in the model definition.
+
+This summary is useful for documentation, collaboration, and reproducibility.
+
+---
+
+## Usage
+
+### Basic Example
+
+```python
+from qspy.core import Model
+
+# Build your model here...
+
+model = Model().with_units(...)
+# ... define monomers, parameters, rules, etc.
+
+# Generate a summary file (default location: SUMMARY_DIR)
+model.markdown_summary()
+```
+
+### Custom Output Path
+
+You can specify a custom output path for the summary file:
+
+```python
+model.markdown_summary(path="my_model_summary.md")
+```
+
+### Including a Model Diagram
+
+If you include an instance of `ModelMermaidDiagrammer` in the model definition then the corresponding diagram will be included in the summary:
+
+```python
+model.markdown_summary(include_diagram=True)
+```
+
+## Example Output
+
+A generated summary file (Markdown) will look like:
+
+```markdown
+# QSPy Model Summary: `MyModel`
+
+**Model name**: `MyModel`
+**Hash**: `abcd1234`
+**Version**: 1.0.0
+**Author**: Alice
+**Executed by**: alice
+**Timestamp**: 2025-07-02T12:34:56
+
+## 🖼️ Model Diagram
+
+
+
+## Core Units
+| Quantity | Unit |
+|-------------- |------|
+| Concentration | nM |
+| Time | h |
+| Volume | L |
+
+## Model Component Counts
+| Component Type | Count |
+|---------------------|-------|
+| Monomers | 2 |
+| Parameters | 2 |
+| Expressions | 0 |
+| Compartments | 0 |
+| Rules | 1 |
+| Initial Conditions | 2 |
+| Observables | 2 |
+
+## Compartments
+| Name | Size |
+|-------|------|
+| _None_ | _N/A_ |
+
+## Monomers
+| Name | Sites | States | Functional Tag |
+|------|---------|-----------------------------|---------------------|
+| A | ['b'] | {'b': ['u', 'p']} | protein::ligand |
+| B | [] | {} | protein::receptor |
+
+## Parameters
+| Name | Value | Units |
+|------|-------|-------|
+| k1 | 1.0 | 1/min |
+| k2 | 0.5 | 1/min |
+
+## Expressions
+| Name | Expression |
+|------|------------|
+| _None_ | _N/A_ |
+
+## Initial Conditions
+| Species | Value | Units |
+|-----------|-------|-------|
+| A(b=None) | 100 | nM |
+| B() | 200 | nM |
+
+## Rules
+| Name | Rule Expression | k_f | k_r | reversible |
+|------|----------------------------------------|-----|------|------------|
+| bind | `A(b=None) + B() >> A(b=1) % B()` | k1 | None | False |
+
+## Observables
+| Name | Reaction Pattern |
+|----------|------------------|
+| A_total | `A()` |
+| B_total | `B()` |
+```
+
+## Why Use Model Summaries?
+
+- **Documentation**: Quickly generate a comprehensive overview of your model for reports or publications.
+- **Collaboration**: Share model structure and assumptions with colleagues.
+- **Reproducibility**: Archive model state and metadata alongside simulation results.
+
+## See Also
+
+- [Model Metadata Tracking](./metadata-tracking.md)
diff --git a/docs/outputs-logs.md b/docs/outputs-logs.md
new file mode 100644
index 0000000..2cdbef1
--- /dev/null
+++ b/docs/outputs-logs.md
@@ -0,0 +1,63 @@
+# QSPy Outputs and Logs
+
+QSPy automatically generates a variety of outputs and logs to help you track, audit, and reproduce your modeling work. By default, these files are stored in a hidden `.qspy` folder in your project directory.
+
+---
+
+## What is in the `.qspy` Folder?
+
+The `.qspy` folder is created automatically when you run QSPy code. It contains:
+
+- **Model summaries:** Markdown files summarizing model structure, parameters, and metadata (output from the `Model.markdown_summary` function)
+- **Model diagrams:** Mermaid or image files visualizing model architecture (if enabled by using the `ModelMermaidDiagrammer`).
+- **Run logs:** Detailed logs of model construction, macro usage, and simulation runs.
+- **Audit trails:** Metadata and hashes for reproducibility and version tracking (if enabled by using the `ModelMetadataTracker`).
+
+This folder is intended to be a central location for all QSPy-generated artifacts, making it easy to review your modeling workflow and share results.
+
+---
+
+## Example Contents
+
+```
+.qspy/
+├── model_summary.md
+├── model_diagram.mmd
+├── logs/
+├───|──── qspy.log
+├── metadata/
+├───|──── model-name__author__short-hash__time.toml
+└── ...
+```
+
+---
+
+## Changing the Output Location
+
+By default, QSPy writes all outputs and logs to `.qspy` in the current working directory. You can change this location using the `qspy.config` module.
+
+!!! example "Change the output directory"
+ ```python
+ import qspy.config
+
+ # Set a new output directory for QSPy logs and artifacts
+ qspy.config.set_output_dir("my_outputs/qspy_artifacts")
+ ```
+
+This will update `qspy.config.OUTPUT_DIR`, and all new logs and outputs will be written to the specified folder.
+
+---
+
+## Tips
+
+- **Version control:** You may want to add `.qspy/` to your `.gitignore` if you do not wish to track logs and outputs in version control.
+- **Reproducibility:** The logs and metadata in `.qspy` are useful for reproducing results and tracking model changes over time.
+- **Cleanup:** You can safely delete the `.qspy` folder if you want to clear outputs; it will be recreated as needed.
+
+---
+
+## See Also
+
+- [Model Summary Generator](model-summary-generator.md)
+- [Model Diagram Generator](./model-diagram-generator.md)
+- [Metadata Tracking](./metadata-tracking.md)
\ No newline at end of file
diff --git a/docs/qsp-modeling.md b/docs/qsp-modeling.md
new file mode 100644
index 0000000..d0b27c0
--- /dev/null
+++ b/docs/qsp-modeling.md
@@ -0,0 +1,27 @@
+# What is QSP Modeling?
+
+[Quantitative systems pharmacology (QSP)](https://en.wikipedia.org/wiki/Quantitative_systems_pharmacology) integrates traditional pharmacological modeling (e.g., PK/PD modeling) and mechanistic systems biology modeling (e.g., biochemical network modeling) to better understand how drugs interact with biological targets, affect biochemical pathways, and how biological systems and diseases respond to drug intervention. The resulting mathematical models can thus integrate knowledge and data on drug pharmacokinetics, drug mechanism-of-action (MoA), biological pathways, and disease processes to better understand drug effects and treatment potential.
+
+QSP models are particularly useful for in silico hypothesis testing and prediction, supporting drug discovery and development decisions in areas such as target selection, dose optimization, and precision medicine.
+
+The following diagram gives a reasonable overview of QSP models and an associated modeling workflow:
+
+
+[Figure 1 of [Helmlinger et al. 2019, Quantitative Systems Pharmacology: An Exemplar Model‐Building Workflow With Applications in Cardiovascular, Metabolic, and Oncology Drug Development, CPT Pharmacometrics Syst. Pharmacol., 8: 380-395](https://doi.org/10.1002/psp4.12426) | reproduced here without modification under [Creative Commons License](https://ascpt.onlinelibrary.wiley.com/hub/journal/21638306/about/permissions)]
+
+This diagram shows a systems biology modeling workflow, which is also highly applicable to QSP modeling:
+
+
+[Figure 2 of [Guimera et al., Systems modelling ageing: from single senescent cells to simple multi-cellular models, Essays Biochem (2017) 61 (3): 369–377.](https://doi.org/10.1042/EBC20160087) | reproduced here without modification under [CC BY License](https://portlandpress.com/pages/open_access_policy)]
+
+---
+
+## Learn More
+
+External Resources on QSP:
+
+* [What Is Quantitative Systems Pharmacology? | MathWorks Discovery Page](https://www.mathworks.com/discovery/quantitative-systems-pharmacology.html)
+* [Benefits & Uses of QSP in Drug Development | Allucent Blog Post](https://www.allucent.com/resources/blog/benefits-and-uses-qsp-drug-development)
+* [Mathematical Sandbox: How Quantitative Systems Pharmacology Steers Safer, Faster Drug Development | Pfizer News Article](https://www.pfizer.com/news/articles/mathematical_sandbox_how_quantitative_systems_pharmacology_steers_safer_faster_drug_development)
+* [Quantitative Systems Pharmacology (QSP): Past, Present and Future | Certara YouTube Video](https://youtu.be/h2ttKjiWeuA?si=JZpTqTGjP8bw56xr)
+* [History and Future Perspectives on the Discipline of Quantitative Systems Pharmacology Modeling and Its Applications | Academic Review Article - Open Access](https://www.frontiersin.org/journals/physiology/articles/10.3389/fphys.2021.637999/full)
\ No newline at end of file
diff --git a/docs/reference.md b/docs/reference.md
new file mode 100644
index 0000000..0801792
--- /dev/null
+++ b/docs/reference.md
@@ -0,0 +1,60 @@
+# API Reference
+
+::: qspy.core
+ options:
+ show_root_heading: true
+
+::: qspy.config
+ options:
+ show_root_heading: true
+
+::: qspy.contexts.contexts
+ options:
+ show_root_heading: true
+
+::: qspy.contexts.base
+ options:
+ show_root_heading: true
+
+::: qspy.functionaltags
+ options:
+ show_root_heading: true
+
+::: qspy.validation.metadata
+ options:
+ show_root_heading: true
+
+::: qspy.validation.modelchecker
+ options:
+ show_root_heading: true
+
+::: qspy.utils.diagrams
+ options:
+ show_root_heading: true
+
+::: qspy.utils.logging
+ options:
+ show_root_heading: true
+
+## Experimental Features
+
+::: qspy.experimental.infix_macros
+ options:
+ show_root_heading: true
+
+::: qspy.experimental.functional_monomers
+ options:
+ show_root_heading: true
+
+::: qspy.experimental.functional_monomers.protein
+ options:
+ show_root_heading: true
+
+::: qspy.experimental.functional_monomers.base
+ options:
+ show_root_heading: true
+
+::: qspy.experimental.functional_monomers.macros
+ options:
+ show_root_heading: true
+
diff --git a/docs/related-software.md b/docs/related-software.md
new file mode 100644
index 0000000..987b980
--- /dev/null
+++ b/docs/related-software.md
@@ -0,0 +1,52 @@
+# Related Software
+
+There are a variety of pharmacological modeling tools. Below is a (likely non-exhaustive) list of such tools categorized into free/open-source and commercial/proprietary tools, and grouped by programming language where applicable.
+
+## Free & Open-Source Tools
+
+### **Python-Based Solutions**
+
+- **PharmPy** – A Python-based toolkit for nonlinear mixed-effects modeling, focused on PK/PD applications. _(GPL-3.0)_
+- **scipion-pkpd** – A Python plugin for PK/PD modeling within the Scipion workflow engine. _(GPL-3.0)_
+- **Chi** – A Python-based pharmacometrics modeling tool. _(BSD 3-Clause)_
+
+### **R-Based Solutions**
+
+- **PKPDsim** – A package for PK/PD simulations supporting differential equations and stochastic models. _(GPL-3.0)_
+- **mrgsolve** – A model simulation tool designed for population PK/PD analysis. _(MIT License)_
+- **nlme** – A package for fitting nonlinear mixed-effects models, widely used in PK/PD analysis. _(Part of R Base, freely available)_
+- **nlmixr** – A flexible platform for nonlinear mixed-effects modeling, specifically designed for PK/PD applications. _(GPL-2.0)_
+- **rxode2** – A powerful ODE-based solver for PK/PD and pharmacometrics simulations. _(GPL-2.0)_
+- **Ubiquity** – A modeling framework primarily based in R, designed for PK/PD and systems pharmacology applications. _(BSD 3-Clause)_
+
+### **C++/GUI-Based Solutions**
+
+- **BioGears** – A C++-based open-source human physiology simulation engine with a Java-based GUI. _(Apache-2.0 License)_
+
+### **Command-Line Based**
+
+- **GNU MCSim** – A Monte Carlo simulation tool written in C that supports differential equation modeling and Bayesian inference. _(GPL-2.0)_
+
+### **GUI-Based Solutions**
+
+- **Open Systems Pharmacology** – A comprehensive open-source suite for PBPK and PK/PD modeling, including:
+ - **PK-Sim** – A tool for physiologically based pharmacokinetic (PBPK) modeling. _(GPL-2.0)_
+ - **MoBi** – A tool for mechanistic modeling, allowing integration of molecular and cellular processes. _(GPL-2.0)_
+
+### Other Modeling Frameworks
+
+- **Heta Project** – A modeling framework for Quantitative Systems Pharmacology (QSP) and Systems Biology.
+
+## **Commercial & Proprietary Applications**
+
+- **NONMEM** – Industry-standard software for nonlinear mixed-effects modeling, widely used in population PK/PD studies.
+- **Monolix** – A powerful application for model-based PK/PD analysis with a user-friendly interface.
+- **SimBiology (MATLAB)** – A comprehensive system for mechanistic PK/PD and systems pharmacology modeling.
+- **Phoenix WinNonlin** – A suite for pharmacokinetic analysis and regulatory submissions.
+- **PoPy** – A Python-based tool designed for population PK/PD modeling. _(Dual-licensed: Free for academic and educational use, commercial license required for industry applications and regulatory submissions)_
+- **Berkeley Madonna** – A commercial mathematical modeling software for solving differential equations, widely used in pharmacometrics. _(Proprietary License)_
+- **Pumas** – A Julia-based platform for pharmaceutical modeling and simulation. _(Proprietary: Free academic use, commercial license required for industry applications.)_
+
+## Suggestions & Contributions
+
+Notice something missing or an issue in the lists above? Feel free to reach out if you know of a missing pharmacological modeling tool or have a suggestion for an update. We welcome community contributions to enhance the accuracy and usefulness of this resource!
diff --git a/docs/supporting.md b/docs/supporting.md
new file mode 100644
index 0000000..648d856
--- /dev/null
+++ b/docs/supporting.md
@@ -0,0 +1,10 @@
+# Supporting
+
+I'm very happy that you've chosen to use **QSPy**. This project is one that I develop and maintain on my own time and without external funding. If you've found it helpful, here are a few ways you can support its ongoing development:
+
+- **Star** :star: : Show your support by starring the [QSPy GitHub repository](https://github.com/Borealis-BioModeling/qspy). It helps increase the project's visibility and lets others know it's useful. It also benefits my motivation to continue improving the package!
+- **Share** :mega: : Sharing `QSPy` on your social media, forums, or with your network is another great way to support the project. It helps more people discover the package, which in turn motivates me to keep developing!
+- **Cite** :books: : Citing or mentioning this software in your work, publications, or projects is another valuable way to support it. It helps spread the word and acknowledges the effort put into its development, which is greatly appreciated!
+- **Sponsor** :dollar: : Even small financial contributions, such as spotting me the cost of a tea through Ko-fi so I can get my caffeine fix, can make a big difference! Every little bit can help me continue developing this and other open-source projects.
+
+[](https://ko-fi.com/J3J4ZUCVU)
diff --git a/docs/tutorials.md b/docs/tutorials.md
new file mode 100644
index 0000000..7885e6f
--- /dev/null
+++ b/docs/tutorials.md
@@ -0,0 +1,12 @@
+# 🚧 Page Under Development 🚧
+
+Thank you for your interest in our **Tutorials** section! We’re actively working on expanding these pages to provide **step-by-step instructions** and **hands-on examples** for using `qspy`.
+
+Our goal is to make these resources **clear, practical, and easy to follow**—but we’re still in the process of gathering content and refining details.
+
+Stay tuned! In the meantime:
+
+- **Have a specific question?** Feel free to explore our existing documentation or reach out to the community.
+- **Want to contribute?** If you have suggestions or example workflows, we'd love to hear from you!
+
+Check back soon for updates as we continue to improve these guides!
\ No newline at end of file
diff --git a/environment.yml b/environment.yml
new file mode 100644
index 0000000..3933e41
--- /dev/null
+++ b/environment.yml
@@ -0,0 +1,228 @@
+name: qspy
+channels:
+ - conda-forge
+ - alubbock
+ - defaults
+dependencies:
+ - appdirs=1.4.4
+ - astropy=5.3.4
+ - bionetgen=2.5.1
+ - blas=1.0
+ - brotli=1.0.9
+ - brotli-bin=1.0.9
+ - brotlipy=0.7.0
+ - bzip2=1.0.8
+ - ca-certificates=2024.3.11
+ - contourpy=1.0.5
+ - cryptography=39.0.1
+ - cycler=0.11.0
+ - cython=0.29.33
+ - fonttools=4.25.0
+ - freetype=2.12.1
+ - giflib=5.2.1
+ - glib=2.69.1
+ - gst-plugins-base=1.18.5
+ - gstreamer=1.18.5
+ - icc_rt=2022.1.0
+ - icu=58.2
+ - idna=3.4
+ - intel-openmp=2023.1.0
+ - jpeg=9e
+ - kiwisolver=1.4.4
+ - krb5=1.19.4
+ - lerc=3.0
+ - libbrotlicommon=1.0.9
+ - libbrotlidec=1.0.9
+ - libbrotlienc=1.0.9
+ - libclang=12.0.0
+ - libclang13=14.0.6
+ - libdeflate=1.17
+ - libffi=3.4.4
+ - libiconv=1.16
+ - libogg=1.3.5
+ - libpng=1.6.39
+ - libtiff=4.5.0
+ - libvorbis=1.3.7
+ - libwebp=1.2.4
+ - libwebp-base=1.2.4
+ - libxml2=2.10.3
+ - libxslt=1.1.37
+ - lz4-c=1.9.4
+ - matplotlib=3.7.1
+ - matplotlib-base=3.7.1
+ - mkl=2023.1.0
+ - mkl-service=2.4.0
+ - mkl_fft=1.3.6
+ - mkl_random=1.2.2
+ - mpmath=1.2.1
+ - munkres=1.1.4
+ - networkx=2.8.4
+ - nfsim=1.12.1
+ - nodejs=20.8.0
+ - numpy=1.24.3
+ - numpy-base=1.24.3
+ - openssl=3.0.13
+ - packaging=23.2
+ - pcre=8.45
+ - perl=5.32.1.1
+ - pillow=9.4.0
+ - ply=3.11
+ - pooch=1.4.0
+ - pycparser=2.21
+ - pyerfa=2.0.0
+ - pyopenssl=23.0.0
+ - pyparsing=3.0.9
+ - pyqt=5.15.7
+ - pyqt5-sip=12.11.0
+ - pysb=1.15.0
+ - pysocks=1.7.1
+ - python=3.11.9
+ - python-dateutil=2.8.2
+ - pyyaml=6.0.1
+ - qt-main=5.15.2
+ - qt-webengine=5.15.9
+ - qtwebkit=5.212
+ - scipy=1.10.1
+ - setuptools=66.0.0
+ - sip=6.6.2
+ - six=1.16.0
+ - sqlite=3.41.2
+ - sympy=1.11.1
+ - tbb=2021.8.0
+ - tk=8.6.12
+ - toml=0.10.2
+ - vc=14.2
+ - vs2015_runtime=14.27.29016
+ - wheel=0.38.4
+ - win_inet_pton=1.1.0
+ - xz=5.4.6
+ - yaml=0.2.5
+ - zlib=1.2.13
+ - zstd=1.5.5
+ - pip:
+ - anyio==4.0.0
+ - argon2-cffi==23.1.0
+ - argon2-cffi-bindings==21.2.0
+ - arrow==1.3.0
+ - asttokens==2.4.0
+ - async-lru==2.0.4
+ - attrs==23.1.0
+ - babel==2.13.0
+ - backcall==0.2.0
+ - backrefs==5.8
+ - beautifulsoup4==4.12.2
+ - bleach==6.1.0
+ - certifi==2023.7.22
+ - cffi==1.16.0
+ - charset-normalizer==3.3.0
+ - click==8.2.1
+ - colorama==0.4.6
+ - comm==0.1.4
+ - coverage==7.6.10
+ - debugpy==1.8.0
+ - decorator==5.1.1
+ - defusedxml==0.7.1
+ - docstring-parser==0.16
+ - executing==2.0.0
+ - fastjsonschema==2.18.1
+ - fqdn==1.5.1
+ - ghp-import==2.1.0
+ - griffe==1.7.3
+ - iniconfig==2.0.0
+ - ipykernel==6.25.2
+ - ipython==8.16.1
+ - ipython-genutils==0.2.0
+ - ipywidgets==8.1.1
+ - isoduration==20.11.0
+ - jedi==0.19.1
+ - jinja2==3.1.2
+ - json5==0.9.14
+ - jsonpointer==2.4
+ - jsonschema==4.19.1
+ - jsonschema-specifications==2023.7.1
+ - jupyter-client==8.3.1
+ - jupyter-core==5.3.2
+ - jupyter-events==0.7.0
+ - jupyter-lsp==2.2.0
+ - jupyter-server==2.7.3
+ - jupyter-server-terminals==0.4.4
+ - jupyterlab==4.0.6
+ - jupyterlab-pygments==0.2.2
+ - jupyterlab-server==2.25.0
+ - jupyterlab-widgets==3.0.9
+ - markdown==3.8
+ - markupsafe==2.1.3
+ - matplotlib-inline==0.1.6
+ - mergedeep==1.3.4
+ - mergram==0.3.0
+ - microbench==0.9.1
+ - mistune==3.0.2
+ - mkdocs==1.6.1
+ - mkdocs-autorefs==1.4.2
+ - mkdocs-get-deps==0.2.0
+ - mkdocs-material==9.6.14
+ - mkdocs-material-extensions==1.3.1
+ - mkdocstrings==0.29.1
+ - mkdocstrings-python==1.16.11
+ - nbclassic==0.5.6
+ - nbclient==0.8.0
+ - nbconvert==7.9.2
+ - nbformat==5.9.2
+ - nest-asyncio==1.5.8
+ - nose==1.3.7
+ - notebook-shim==0.2.3
+ - overrides==7.4.0
+ - paginate==0.5.7
+ - pandas==2.3.1
+ - pandocfilters==1.5.0
+ - parso==0.8.3
+ - pathspec==0.12.1
+ - pickleshare==0.7.5
+ - pip==25.1.1
+ - platformdirs==3.11.0
+ - pluggy==1.5.0
+ - prometheus-client==0.17.1
+ - prompt-toolkit==3.0.39
+ - psutil==5.9.5
+ - pubchempy==1.0.4
+ - pure-eval==0.2.2
+ - pygments==2.16.1
+ - pymdown-extensions==10.15
+ - pyrsistent==0.19.3
+ - pysb-pkpd==0.5.3
+ - pysb-units==0.4.1
+ - pytest==8.3.4
+ - python-json-logger==2.0.7
+ - python-louvain==0.16
+ - pytkdocs==0.16.5
+ - pytz==2025.2
+ - pyvipr==1.0.7
+ - pywin32==306
+ - pywinpty==2.0.12
+ - pyyaml-env-tag==1.1
+ - pyzmq==25.1.1
+ - referencing==0.30.2
+ - requests==2.31.0
+ - rfc3339-validator==0.1.4
+ - rfc3986-validator==0.1.1
+ - rpds-py==0.10.4
+ - seaborn==0.13.2
+ - send2trash==1.8.2
+ - sniffio==1.3.0
+ - soupsieve==2.5
+ - stack-data==0.6.3
+ - terminado==0.17.1
+ - tinycss2==1.2.1
+ - tornado==6.3.3
+ - traitlets==5.11.2
+ - types-python-dateutil==2.8.19.14
+ - typing-extensions==4.6.2
+ - tzdata==2025.2
+ - uri-template==1.3.0
+ - urllib3==2.0.6
+ - watchdog==6.0.0
+ - wcwidth==0.2.8
+ - webcolors==1.13
+ - webencodings==0.5.1
+ - websocket-client==1.6.4
+ - widgetsnbextension==4.0.9
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..f3bf6ed
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,80 @@
+site_name: QSPy
+
+theme:
+ name: "material"
+ palette:
+ - primary: teal
+ - accent: amber
+
+plugins:
+ - search # support for search bar
+ - mkdocstrings: # mkdocstrings - needed for making docs entries from docstrings
+ handlers:
+ python:
+ options:
+ docstring_style: numpy
+ paths: [.] # search packages in the src folder
+
+markdown_extensions:
+ - attr_list
+ - md_in_html
+ # emoji rendering using emoji shortcodes
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ # Syntax highlighting
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
+ - pymdownx.superfences
+ - pymdownx.arithmatex:
+ generic: true
+ # Tabbed content
+ - pymdownx.tabbed:
+ alternate_style: true
+ # Admonitions
+ - admonition
+ - pymdownx.details
+
+# math rendering using Mathjax
+extra_javascript:
+ - javascripts/mathjax.js
+ - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js
+
+
+nav:
+ - Home: index.md
+ - User Guide:
+ - Getting Started: getting-started.md
+ - Model Components: model-components.md
+ - Model Specification: model-specification.md
+ - Functional Tags: functional-tags.md
+ - Model Checker: model-checker.md
+ - Metadata Tracking: metadata-tracking.md
+ - Model Diagram Generator: model-diagram-generator.md
+ - Model Summary Generator: model-summary-generator.md
+ - Outputs & Logs: outputs-logs.md
+ - Experimental Features: experimental.md
+ - How-To Guides: how-to-guides.md
+ - Tutorials: tutorials.md
+ - API Reference: reference.md
+ - About:
+ - About QSPy: about-qspy.md
+ - License: license.md
+ - Citing: citing.md
+ - Source Code: https://github.com/Borealis-BioModeling/qspy
+ - Community:
+ - Contributing: contributing.md
+ - Supporting: supporting.md
+ - QSPy Gitter: https://matrix.to/#/#qspy:gitter.im
+ - QSPy Discussions: https://github.com/Borealis-BioModeling/qspy/discussions
+ - Resources:
+ - What is QSP Modeling: qsp-modeling.md
+ - Related Software: related-software.md
+ - PySB Home: https://pysb.org/
+ - PySB Documentation: https://pysb.readthedocs.io/en/stable/
+ - PySB GitHub: https://github.com/pysb/pysb
+ - Contact/Support: contact-support.md
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5d36fd8
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,79 @@
+[build-system]
+requires = ["setuptools >= 61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "qspy"
+dynamic = ["version"]
+requires-python = ">= 3.11.9"
+dependencies = [
+ "pysb>=1.15.0",
+ "pysb-pkpd>=0.5.3",
+ "pysb-units>=0.4.0",
+ "microbench>=0.9.1",
+]
+authors = [
+ {name = "Blake A. Wilson", email = "blakeaw1102@gmail.com"},
+]
+description = "Rule-based Programmatic Quantitative Systems Pharmacology Modeling in Python."
+readme = "README.md"
+keywords = ["qsp", "qst", "pkpd", "pysb"]
+classifiers = [
+ # How mature is this project? Common values are
+ # 3 - Alpha
+ # 4 - Beta
+ # 5 - Production/Stable
+ "Development Status :: 3 - Alpha",
+
+ # Intended Audience
+ "Intended Audience :: Science/Research",
+
+ # Topics
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Scientific/Engineering :: Chemistry",
+
+ # Pick your license as you wish (see also "license" above)
+ "License :: OSI Approved :: BSD License",
+
+ # Specify the Python versions you support here.
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+]
+
+[project.optional-dependencies]
+dev = [
+ "black>=24.4.2",
+ "pytest>=8.3.4",
+ "coverage>=7.6.10",
+ "nose>=1.3.7",
+ "mkdocs>=1.6.1",
+ "mkdocs-material>=9.6.14",
+ "mkdocstrings[python]>=1.16.11",
+ "pytkdocs[numpy-style]>=0.5.0",
+]
+
+test = [
+ "pytest>=8.3.4",
+ "coverage>=7.6.10",
+ "nose>=1.3.7"
+]
+
+docs = [
+ "mkdocs>=1.6.1",
+ "mkdocs-material>=9.6.14",
+ "mkdocstrings[python]>=1.16.11",
+ "pytkdocs[numpy-style]>=0.5.0",
+]
+
+[project.urls]
+Repository = "https://github.com/Borealis-BioModeling/qspy"
+Issues = "https://github.com/Borealis-BioModeling/qspy/issues"
+#Changelog = ""
+
+[tool.setuptools.packages]
+find = {} # Scan the project directory with the default parameters
+
+# Set the dynamic version
+[tool.setuptools.dynamic]
+version = {attr = "qspy.__version__"}
\ No newline at end of file
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..4f5f4a3
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,13 @@
+# pytest.ini
+
+[pytest]
+addopts = -ra
+testpaths =
+ tests
+ qspy
+
+markers =
+ unit: marks fast, isolated unit tests (run with -m "unit")
+ integration: marks integration tests involving multiple components or IO
+ slow: marks slow-running tests (e.g., full simulations)
+ regression: marks tests that check for previously fixed bugs
\ No newline at end of file
diff --git a/qspy/__init__.py b/qspy/__init__.py
new file mode 100644
index 0000000..1262827
--- /dev/null
+++ b/qspy/__init__.py
@@ -0,0 +1,68 @@
+"""
+QSPy: Quantitative Systems Pharmacology Modeling Toolkit
+=======================================================
+
+QSPy is an open-source Python package for building, simulating, and analyzing
+quantitative systems pharmacology (QSP) models. It provides a modern, extensible
+API for model construction, metadata tracking, reproducibility, and integration
+with the PySB and scientific Python ecosystem.
+
+Modules
+-------
+- Model construction and context managers
+- Metadata tracking and export
+- Model summary generation
+- Logging utilities
+- Integration with PySB PKPD simulation tools
+
+"""
+
+from qspy.config import QSPY_VERSION
+
+__version__ = QSPY_VERSION
+
+from qspy.core import * # Import core model components
+from qspy.contexts import * # Import context managers for parameters, expressions, compartments, etc.
+from qspy.validation import (
+ ModelMetadataTracker,
+ ModelChecker,
+) # Import validation tools
+from qspy.utils.diagrams import (
+ ModelMermaidDiagrammer,
+) # Import diagram generation tools
+from qspy.functionaltags import * # Import functional tags for model components
+from pysb.pkpd import simulate
+
+
+__all__ = [
+ "Model",
+ "Parameter",
+ "Monomer",
+ "Expression",
+ "Rule",
+ "Compartment",
+ "Observable",
+ "Initial",
+ "ANY",
+ "WILD",
+ "parameters",
+ "expressions",
+ "compartments",
+ "monomers",
+ "initials",
+ "rules",
+ "observables",
+ "macros",
+ "simulate",
+ "ModelMetadataTracker",
+ "ModelChecker",
+ "ModelMermaidDiagrammer",
+ "PROTEIN",
+ "DRUG",
+ "RNA",
+ "DNA",
+ "METABOLITE",
+ "ION",
+ "LIPID",
+ "NANOPARTICLE",
+]
diff --git a/qspy/config.py b/qspy/config.py
new file mode 100644
index 0000000..2ec5d16
--- /dev/null
+++ b/qspy/config.py
@@ -0,0 +1,82 @@
+"""
+QSPy Configuration Module
+=========================
+
+This module centralizes configuration constants for QSPy, including logging,
+unit defaults, output/reporting directories, and versioning information.
+
+Attributes
+----------
+LOGGER_NAME : str
+ Name of the logger used throughout QSPy.
+LOG_PATH : Path
+ Path to the QSPy log file.
+DEFAULT_UNITS : dict
+ Default units for concentration, time, and volume.
+METADATA_DIR : Path
+ Directory for storing model metadata files.
+SUMMARY_DIR : Path
+ Path for the model summary markdown file.
+QSPY_VERSION : str
+ The current version of QSPy.
+"""
+
+from pathlib import Path
+
+OUTPUT_DIR = Path(".qspy")
+
+# Logging
+LOGGER_NAME = "qspy"
+LOG_PATH = OUTPUT_DIR / "logs/qspy.log"
+
+# Unit defaults
+DEFAULT_UNITS = {"concentration": "mg/L", "time": "h", "volume": "L"}
+
+# Output & reporting
+METADATA_DIR = OUTPUT_DIR / "metadata"
+SUMMARY_DIR = OUTPUT_DIR / "model_summary.md"
+
+# Versioning
+QSPY_VERSION = "0.1.0"
+
+def set_output_dir(path: str | Path):
+ """
+ Set the output directory for QSPy reports and metadata.
+
+ Parameters
+ ----------
+ path : Path
+ The new output directory path.
+ """
+ global OUTPUT_DIR, LOG_PATH, METADATA_DIR, SUMMARY_DIR
+ OUTPUT_DIR = Path(path)
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ global LOG_PATH, METADATA_DIR, SUMMARY_DIR
+ LOG_PATH = OUTPUT_DIR / "logs/qspy.log"
+ METADATA_DIR = OUTPUT_DIR / "metadata"
+ SUMMARY_DIR = OUTPUT_DIR / "model_summary.md"
+
+def set_log_path(path: str | Path):
+ """
+ Set the log file path for QSPy.
+
+ Parameters
+ ----------
+ path : Path
+ The new log file path.
+ """
+ global LOG_PATH
+ LOG_PATH = Path(path)
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+def set_logger_name(name: str):
+ """
+ Set the logger name for QSPy.
+
+ Parameters
+ ----------
+ name : str
+ The new logger name.
+ """
+ global LOGGER_NAME
+ LOGGER_NAME = name
\ No newline at end of file
diff --git a/qspy/contexts/__init__.py b/qspy/contexts/__init__.py
new file mode 100644
index 0000000..b0deb97
--- /dev/null
+++ b/qspy/contexts/__init__.py
@@ -0,0 +1,4 @@
+from qspy.contexts.contexts import *
+from qspy.contexts import contexts as contexts_module
+
+__all__ = [] + contexts_module.__all__
\ No newline at end of file
diff --git a/qspy/contexts/base.py b/qspy/contexts/base.py
new file mode 100644
index 0000000..bd6c488
--- /dev/null
+++ b/qspy/contexts/base.py
@@ -0,0 +1,386 @@
+"""
+QSPy Base Context Infrastructure
+===============================
+
+This module provides the abstract base class for QSPy context managers, which
+enable structured and validated construction of model components (parameters,
+monomers, compartments, etc.) in a PySB/QSPy model. The ComponentContext class
+handles introspection, variable tracking, and component injection for both
+manual and automatic (module-level) usage.
+
+Classes
+-------
+ComponentContext : Abstract base class for all QSPy context managers.
+
+Examples
+--------
+>>> class MyContext(ComponentContext):
+... def create_component(self, name, *args):
+... # Custom creation logic
+... pass
+...
+>>> with MyContext():
+... my_param = (1.0, "1/min")
+"""
+
+import copy
+import inspect
+import logging
+import weakref
+from abc import ABC, abstractmethod
+from types import ModuleType
+
+from pysb.core import SelfExporter, ComponentSet
+
+from qspy.config import LOGGER_NAME
+from qspy.utils.logging import ensure_qspy_logging
+
+
+# Types to skip during introspection
+# These are typically PySB components or context objects that don't need to be tracked/deep
+# copied.
+SKIP_TYPES = (
+ "Parameter",
+ "Monomer",
+ "Rule",
+ "Compartment",
+ "Observable",
+ "Expression",
+ "Initial",
+ "ComponentContext",
+ "parameters",
+ "expressions",
+ "compartments",
+ "monomers",
+ "initials",
+ "rules",
+ "observables",
+ "Model",
+ "model",
+ "__name__",
+ "__doc__",
+ "__package__",
+ "__file__",
+ "__loader__",
+ "__spec__",
+ "__cached__",
+ "__builtins__",
+ "__version__",
+ "__author__",
+ "pytest",
+ "pysb",
+ "pysb.core",
+ "pysb.macros",
+ "pysb.pkpd.macros",
+ "pysb.pkpd",
+)
+
+def is_module(obj):
+ """
+ Check if the object is a module.
+
+ Parameters
+ ----------
+ obj : object
+ The object to check.
+
+ Returns
+ -------
+ bool
+ True if the object is a module, False otherwise.
+ """
+ return isinstance(obj, ModuleType)
+
+class ComponentContext(ABC):
+ """
+ Abstract base class for QSPy context managers.
+
+ Handles variable introspection, manual and automatic component addition,
+ and provides a template for context-managed model construction.
+
+ Parameters
+ ----------
+ manual : bool, optional
+ If True, enables manual mode for explicit component addition (default: False).
+ verbose : bool, optional
+ If True, prints verbose output during component addition (default: False).
+
+ Attributes
+ ----------
+ component_name : str
+ Name of the component type (e.g., 'parameter', 'monomer').
+ model : pysb.Model
+ The active PySB model instance.
+ _manual_adds : list
+ List of manually added components (used in manual mode).
+ _frame : frame
+ Reference to the caller's frame for introspection.
+ _locals_before : dict
+ Snapshot of local variables before entering the context.
+ _override : bool
+ If True, disables module-scope enforcement.
+
+ Methods
+ -------
+ __enter__()
+ Enter the context, track local variables, and enforce module scope.
+ __exit__(exc_type, exc_val, exc_tb)
+ Exit the context, detect new variables, validate, and add components.
+ __call__(name, *args)
+ Add a component manually (manual mode only).
+ _add_component(name, *args)
+ Validate and add a component to the model.
+ _validate_value(name, val)
+ Validate or transform the right-hand-side value (override in subclasses).
+ create_component(name, *args)
+ Abstract method to create a component (must be implemented in subclasses).
+ add_component(component)
+ Add the component to the model.
+ """
+
+ component_name = "component" # e.g. 'parameter', 'monomer'
+
+ def __init__(self, manual: bool = False, verbose: bool = False):
+ """
+ Initialize the context manager.
+
+ Parameters
+ ----------
+ manual : bool, optional
+ If True, enables manual mode for explicit component addition (default: False).
+ verbose : bool, optional
+ If True, prints verbose output during component addition (default: False).
+ """
+ self.manual = manual
+ self.verbose = verbose
+ self._manual_adds = []
+ self._frame = None
+ self._locals_before = None
+ # self.components = ComponentSet()
+ self.model = SelfExporter.default_model
+ self._override = False
+
+ def __enter__(self):
+ """
+ Enter the context manager.
+
+ Tracks local variables for automatic detection of new assignments.
+ Enforces module-level usage unless manual mode or override is enabled.
+
+ Returns
+ -------
+ self or None
+ Returns self in manual mode, otherwise None.
+
+ Raises
+ ------
+ RuntimeError
+ If no active model is found or used outside module scope.
+ """
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ try:
+ logger.info(f"[QSPy] Entering context: {self.__class__.__name__}")
+ if self.model is None:
+ logger.error("No active model found. Did you instantiate a Model()?")
+ raise RuntimeError(
+ "No active model found. Did you instantiate a Model()?"
+ )
+
+ if self._override:
+ self._frame = inspect.currentframe().f_back.f_back
+ else:
+ self._frame = inspect.currentframe().f_back
+
+ # Require module-level use for introspection mode
+ if (
+ (not self.manual)
+ and (self._frame.f_globals is not self._frame.f_locals)
+ ) and (not self._override):
+ logger.error(
+ f"{self.__class__.__name__} must be used at module scope. "
+ f"Wrap model components in a module-level script."
+ )
+ raise RuntimeError(
+ f"{self.__class__.__name__} must be used at module scope. "
+ f"Wrap model components in a module-level script."
+ )
+
+ if not self.manual:
+ # Filter out model components and context objects before deepcopy
+
+ filtered_locals = {
+ k: v
+ for k, v in self._frame.f_locals.items()
+ if not ((hasattr(v, "__class__") and k in SKIP_TYPES) or is_module(v))
+ }
+ # print(filtered_locals)
+ for key in filtered_locals:
+ logger.debug(f"Local variable: {key}")
+ self._locals_before = copy.deepcopy(filtered_locals)
+
+ return self if self.manual else None
+ except Exception as e:
+ logger.error(f"[QSPy][ERROR] Exception on entering context: {e}")
+ raise
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """
+ Exit the context manager.
+
+ Detects new variables, validates, and adds components to the model.
+ In manual mode, adds components explicitly provided via __call__.
+
+ Parameters
+ ----------
+ exc_type : type
+ Exception type, if any.
+ exc_val : Exception
+ Exception value, if any.
+ exc_tb : traceback
+ Traceback, if any.
+
+ Returns
+ -------
+ None
+ """
+
+ logger = logging.getLogger(LOGGER_NAME)
+ try:
+ logger.info(f"[QSPy] Exiting context: {self.__class__.__name__}")
+ if self.manual:
+ for name, *args in self._manual_adds:
+ self._add_component(name, *args)
+ else:
+ # Filter out model components and context objects before comparison
+
+ filtered_locals = {
+ k: v
+ for k, v in self._frame.f_locals.items()
+ if not ((hasattr(v, "__class__") and k in SKIP_TYPES) or is_module(v))
+ }
+ new_vars = set(filtered_locals.keys()) - set(self._locals_before.keys())
+
+ for var_name in new_vars:
+ val = filtered_locals[var_name]
+ args = self._validate_value(var_name, val)
+ # Remove the name from the frame locals so it
+ # can be re-added as the component.
+ del self._frame.f_locals[var_name]
+ self._add_component(var_name, *args)
+ # for component in self.components:
+ # if component.name in set(self._frame.f_locals.keys()):
+ # self._frame.f_locals[component.name] = component
+ except Exception as e:
+ logger.error(f"[QSPy][ERROR] Exception on exiting context: {e}")
+
+ def __call__(self, name, *args):
+ """
+ Add a component manually (manual mode only).
+
+ Parameters
+ ----------
+ name : str
+ Name of the component.
+ *args
+ Arguments for component creation.
+
+ Raises
+ ------
+ RuntimeError
+ If called when manual mode is not enabled.
+ """
+ if not self.manual:
+ raise RuntimeError(
+ f"Manual mode is not enabled for this {self.__class__.__name__}"
+ )
+ self._manual_adds.append((name, *args))
+
+ def _add_component(self, name, *args):
+ """
+ Validate and add a component to the model.
+
+ Parameters
+ ----------
+ name : str
+ Name of the component.
+ *args
+ Arguments for component creation.
+
+ Raises
+ ------
+ ValueError
+ If the component already exists in the model.
+ """
+ if name in self.model.component_names:
+ raise ValueError(
+ f"{self.component_name.capitalize()} '{name}' already exists in the model."
+ )
+
+ component = self.create_component(name, *args)
+ self.add_component(component)
+
+ if self.verbose:
+ print(f"[{self.component_name}] Added: {name} with args: {args}")
+
+ def _validate_value(self, name, val):
+ """
+ Validate or transform the right-hand-side value.
+
+ Override in subclasses if custom validation or transformation is needed.
+
+ Parameters
+ ----------
+ name : str
+ Name of the variable.
+ val : object
+ Value assigned to the variable.
+
+ Returns
+ -------
+ tuple
+ Arguments to be passed to create_component.
+
+ Raises
+ ------
+ ValueError
+ If the value is not a tuple.
+ """
+ if not isinstance(val, tuple):
+ raise ValueError(
+ f"{self.component_name.capitalize()} '{name}' must be defined as a tuple."
+ )
+ return val
+
+ @abstractmethod
+ def create_component(self, name, *args):
+ """
+ Abstract method to create a component.
+
+ Must be implemented in subclasses.
+
+ Parameters
+ ----------
+ name : str
+ Name of the component.
+ *args
+ Arguments for component creation.
+
+ Raises
+ ------
+ NotImplementedError
+ Always, unless implemented in subclass.
+ """
+ raise NotImplementedError
+
+ def add_component(self, component):
+ """
+ Add the component to the model.
+
+ Parameters
+ ----------
+ component : pysb.Component
+ The component to add.
+ """
+ # self.components.add(component)
+ self.model.add_component(component)
diff --git a/qspy/contexts/contexts.py b/qspy/contexts/contexts.py
new file mode 100644
index 0000000..ac61dd0
--- /dev/null
+++ b/qspy/contexts/contexts.py
@@ -0,0 +1,626 @@
+"""
+QSPy Context Managers for Model Construction
+============================================
+
+This module provides context managers and utilities for structured, validated
+construction of quantitative systems pharmacology (QSP) models using QSPy.
+Each context manager encapsulates the logic for defining a specific model
+component (parameters, compartments, monomers, expressions, rules, initials,
+observables), ensuring type safety, unit checking, and extensibility.
+
+Classes
+-------
+parameters : Context manager for defining model parameters (numeric or symbolic).
+compartments : Context manager for defining model compartments.
+monomers : Context manager for defining model monomers with optional functional tags.
+expressions : Context manager for defining model expressions (sympy-based).
+rules : Context manager for defining model rules (reversible/irreversible).
+pk_macros : Stub context manager for future pharmacokinetic macro support.
+
+Functions
+---------
+initials() : Context manager for defining initial conditions.
+observables() : Context manager for defining observables.
+
+Examples
+--------
+>>> with parameters():
+... k1 = (1.0, "1/min")
+
+>>> with monomers():
+... A = (["b"], {"b": ["u", "p"]})
+
+>>> with rules():
+... bind = (A(b=None) + B(), kf, kr)
+"""
+
+import inspect
+import sys
+from contextlib import contextmanager
+from enum import Enum
+
+import sympy
+import pysb
+from pysb.units.core import check as units_check
+
+from qspy.contexts.base import ComponentContext
+from qspy.core import Monomer, Parameter, Expression, Rule, Compartment
+from qspy.config import LOGGER_NAME
+from qspy.utils.logging import log_event
+from qspy.contexts.base import SKIP_TYPES
+
+# from pysb.units import add_macro_units
+# from pysb.pkpd import macros as pkpd
+# from pysb.pkpd import macros as pkpd
+# add_macro_units(pkpd)
+# from pysb.pkpd.macros import eliminate, distribute
+
+# add_macro_units(pkpd)
+
+__all__ = [
+ "parameters",
+ "compartments",
+ "monomers",
+ "expressions",
+ "rules",
+ "initials",
+ "observables",
+ "macros",
+ # "pk_macros",
+ # "units",
+]
+
+# @contextmanager
+# def units(concentration: str = "mg/L", time: str = "h", volume: str = 'L'):
+# try:
+# sim_units = core.SimulationUnits(concentration, time, volume)
+# yield sim_units
+# finally:
+# pass
+
+
+class parameters(ComponentContext):
+ """
+ Context manager for defining model parameters in a QSPy model.
+
+ Provides validation and creation logic for parameters, supporting both numeric
+ and symbolic (sympy.Expr) values.
+
+ Methods
+ -------
+ _validate_value(name, val)
+ Validate the value and unit for a parameter.
+ create_component(name, value, unit)
+ Create a parameter or expression component.
+ """
+
+ component_name = "parameter"
+
+ @staticmethod
+ def _validate_value(name, val):
+ """
+ Validate the value and unit for a parameter.
+
+ Parameters
+ ----------
+ name : str
+ Name of the parameter.
+ val : tuple
+ Tuple of (value, unit).
+
+ Returns
+ -------
+ tuple
+ (value, unit) if valid.
+
+ Raises
+ ------
+ ValueError
+ If the value or unit is invalid.
+ """
+ # Accept sympy expressions directly as expressions
+ if isinstance(val, sympy.Expr):
+ return (val, None)
+ # Ensure tuple structure for numeric parameters
+ if not isinstance(val, tuple) or len(val) != 2:
+ raise ValueError(f"Parameter '{name}' must be a tuple: (value, unit)")
+ value, unit = val
+ if not isinstance(value, (int, float)):
+ raise ValueError(f"Parameter value for '{name}' must be a number")
+ if not isinstance(unit, str):
+ raise ValueError(f"Unit for parameter '{name}' must be a string")
+ return (value, unit)
+
+ @log_event(log_args=True, log_result=True, static_method=True)
+ @staticmethod
+ def create_component(name, value, unit):
+ """
+ Create a parameter or expression component.
+
+ Parameters
+ ----------
+ name : str
+ Name of the parameter.
+ value : int, float, or sympy.Expr
+ Value of the parameter or a sympy expression.
+ unit : str or None
+ Unit for the parameter.
+
+ Returns
+ -------
+ Parameter or Expression
+ The created parameter or expression.
+ """
+ # If value is a sympy expression, create an Expression
+ if isinstance(value, sympy.Expr):
+ expr = Expression(name, value)
+ return expr
+ # Otherwise, create a Parameter
+ param = Parameter(name, value, unit=unit)
+ return param
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """
+ Exit the parameter context and perform unit checking.
+
+ Parameters
+ ----------
+ exc_type : type
+ Exception type, if any.
+ exc_val : Exception
+ Exception value, if any.
+ exc_tb : traceback
+ Traceback, if any.
+
+ Returns
+ -------
+ None
+ """
+ super().__exit__(exc_type, exc_val, exc_tb)
+ # Check units for all parameters in the model
+ units_check(self.model)
+ return
+
+
+class compartments(ComponentContext):
+ """
+ Context manager for defining model compartments in a QSPy model.
+
+ Provides validation and creation logic for compartments.
+
+ Methods
+ -------
+ _validate_value(name, size)
+ Validate the size for a compartment.
+ create_component(name, size)
+ Create a compartment component.
+ """
+
+ component_name = "compartment"
+
+ @staticmethod
+ def _validate_value(name, val):
+ """
+ Validate the size for a compartment.
+
+ Parameters
+ ----------
+ name : str
+ Name of the compartment.
+ val : tuple | Parameter | Expression
+ Tuple of (size, dimensions) or Parameter/Expression for size.
+
+ Returns
+ -------
+ tuple
+ (size, dimensions)
+
+ Raises
+ ------
+ ValueError
+ If the size is not a Parameter or Expression.
+ """
+ if not isinstance(val, (tuple, Parameter, Expression)):
+ raise ValueError(
+ f"Compartment '{name}' must be a tuple: (size, dimensions) or a Parameter/Expression for size"
+ )
+ if isinstance(val, tuple):
+ if len(val) != 2:
+ raise ValueError(
+ f"Compartment '{name}' tuple must be of the form (size, dimensions)"
+ )
+ size, dimensions = val
+ if not isinstance(size, (Parameter, Expression)):
+ raise ValueError(
+ f"Compartment size for '{name}' must be a Parameter or Expression"
+ )
+ if not isinstance(dimensions, int):
+ raise ValueError(
+ f"Compartment dimensions for '{name}' must be an integer"
+ )
+ else:
+ size = val
+ dimensions = 3 # Default to 3D if not specified
+ return (size, dimensions)
+
+ @log_event(log_args=True, log_result=True, static_method=True)
+ @staticmethod
+ def create_component(name, size, dimensions):
+ """
+ Create a compartment component.
+
+ Parameters
+ ----------
+ name : str
+ Name of the compartment.
+ size : Parameter or Expression
+ Size of the compartment.
+
+ Returns
+ -------
+ Compartment
+ The created compartment.
+ """
+ compartment = Compartment(name, size=size, dimension=dimensions)
+ return compartment
+
+
+class monomers(ComponentContext):
+ """
+ Context manager for defining model monomers in a QSPy model.
+
+ Provides validation and creation logic for monomers, including optional functional tags.
+
+ Methods
+ -------
+ _validate_value(name, val)
+ Validate the tuple for monomer definition.
+ create_component(name, sites, site_states, functional_tag)
+ Create a monomer component.
+ """
+
+ component_name = "monomer"
+
+ @staticmethod
+ def _validate_value(name, val):
+ """
+ Validate the tuple for monomer definition.
+
+ Parameters
+ ----------
+ name : str
+ Name of the monomer.
+ val : tuple
+ Tuple of (sites, site_states) or (sites, site_states, functional_tag).
+
+ Returns
+ -------
+ tuple
+ (sites, site_states, functional_tag)
+
+ Raises
+ ------
+ ValueError
+ If the tuple is not valid.
+ """
+ # Accept either (sites, site_states) or (sites, site_states, functional_tag)
+ if not isinstance(val, tuple) or (len(val) not in [2, 3]):
+ raise ValueError(
+ f"Context-defined Monomer '{name}' must be a tuple: (sites, site_states) OR (sites, site_states, functional_tag)"
+ )
+ if len(val) == 2:
+ sites, site_states = val
+ functional_tag = None
+ if len(val) == 3:
+ sites, site_states, functional_tag = val
+ # Validate types for each field
+ if (sites is not None) and (not isinstance(sites, list)):
+ raise ValueError(
+ f"Monomer sites value for '{name}' must be a list of site names"
+ )
+ if (site_states is not None) and (not isinstance(site_states, dict)):
+ raise ValueError(
+ f"Monomer site_states for '{name}' must be a dictionary of sites and their states"
+ )
+ if (functional_tag is not None) and (not isinstance(functional_tag, Enum)):
+ raise ValueError(
+ f"Monomer functional tag for '{name} must be an Enum item'"
+ )
+ return (sites, site_states, functional_tag)
+
+ @log_event(log_args=True, log_result=True, static_method=True)
+ @staticmethod
+ def create_component(name, sites, site_states, functional_tag):
+ """
+ Create a monomer component.
+
+ Parameters
+ ----------
+ name : str
+ Name of the monomer.
+ sites : list
+ List of site names.
+ site_states : dict
+ Dictionary of site states.
+ functional_tag : Enum or None
+ Functional tag for the monomer.
+
+ Returns
+ -------
+ core.Monomer
+ The created monomer.
+ """
+ # If no functional tag, create a plain Monomer
+ if functional_tag is None:
+ monomer = Monomer(name, sites, site_states)
+ else:
+ # If functional tag is provided, attach it
+ monomer = Monomer(name, sites, site_states) @ functional_tag
+ return monomer
+
+
+class expressions(ComponentContext):
+ """
+ Context manager for defining model expressions in a QSPy model.
+
+ Provides validation and creation logic for expressions.
+
+ Methods
+ -------
+ _validate_value(name, val)
+ Validate the value for an expression.
+ create_component(name, expr)
+ Create an expression component.
+ """
+
+ component_name = "expression"
+
+ @staticmethod
+ def _validate_value(name, val):
+ """
+ Validate the value for an expression.
+
+ Parameters
+ ----------
+ name : str
+ Name of the expression.
+ val : sympy.Expr
+ The sympy expression.
+
+ Returns
+ -------
+ tuple
+ (val,)
+
+ Raises
+ ------
+ ValueError
+ If the value is not a sympy.Expr.
+ """
+ # Only allow sympy expressions for expressions
+ if not isinstance(val, sympy.Expr):
+ raise ValueError(f"Expression '{name}' must be a sympy.Expr")
+ return (val,)
+
+ @log_event(log_args=True, log_result=True, static_method=True)
+ @staticmethod
+ def create_component(name, expr):
+ """
+ Create an expression component.
+
+ Parameters
+ ----------
+ name : str
+ Name of the expression.
+ expr : sympy.Expr
+ The sympy expression.
+
+ Returns
+ -------
+ Expression
+ The created expression.
+ """
+ expression = Expression(name, expr)
+ return expression
+
+
+class rules(ComponentContext):
+ """
+ Context manager for defining model rules in a QSPy model.
+
+ Provides validation and creation logic for rules, supporting both reversible and irreversible forms.
+
+ Methods
+ -------
+ _validate_value(name, val)
+ Validate the tuple for rule definition.
+ create_component(name, rxp, rate_forward, rate_reverse)
+ Create a rule component.
+ """
+
+ component_name = "rule"
+
+ @staticmethod
+ def _validate_value(name, val):
+ """
+ Validate the tuple for rule definition.
+
+ Parameters
+ ----------
+ name : str
+ Name of the rule.
+ val : tuple
+ Tuple of (RuleExpression, rate_forward) or (RuleExpression, rate_forward, rate_reverse).
+
+ Returns
+ -------
+ tuple
+ (rxp, rate_forward, rate_reverse)
+
+ Raises
+ ------
+ ValueError
+ If the tuple is not valid or contains invalid types.
+ """
+ # Accept either (RuleExpression, rate_forward) or (RuleExpression, rate_forward, rate_reverse)
+ if not isinstance(val, tuple) or (len(val) < 2 or len(val) > 3):
+ raise ValueError(
+ f"Rule '{name}' input must be a tuple: (RuleExpression, rate_forward) if irreversible or (RuleExpression, rate_forward, rate_reverse) if reversible"
+ )
+ if len(val) == 2:
+ rxp, rate_forward = val
+ rate_reverse = None
+ elif len(val) == 3:
+ rxp, rate_forward, rate_reverse = val
+ # Validate types for rule components
+ if not isinstance(rxp, pysb.RuleExpression):
+ raise ValueError(f"Rule '{name}' must contain a valid RuleExpression")
+ if not isinstance(rate_forward, (Parameter, Expression)):
+ raise ValueError(
+ f"rate_forward value for '{name}' must be a Parameter or Expression"
+ )
+ if (rate_reverse is not None) and not isinstance(
+ rate_forward, (Parameter, Expression)
+ ):
+ raise ValueError(
+ f"rate_reverse value for '{name}' must be a Parameter or Expression"
+ )
+ return (rxp, rate_forward, rate_reverse)
+
+ @log_event(log_args=True, log_result=True, static_method=True)
+ @staticmethod
+ def create_component(name, rxp, rate_forward, rate_reverse):
+ """
+ Create a rule component.
+
+ Parameters
+ ----------
+ name : str
+ Name of the rule.
+ rxp : pysb.RuleExpression
+ The rule expression.
+ rate_forward : Parameter or Expression
+ Forward rate parameter or expression.
+ rate_reverse : Parameter or Expression or None
+ Reverse rate parameter or expression (if reversible).
+
+ Returns
+ -------
+ Rule
+ The created rule.
+ """
+ # Create a Rule object with the provided arguments
+ rule = Rule(name, rxp, rate_forward, rate_reverse)
+ return rule
+
+
+from contextlib import contextmanager
+
+
+@contextmanager
+def initials():
+ """
+ Context manager for defining initial conditions in a QSPy model.
+
+ Tracks which initials are added within the context and logs them.
+
+ Yields
+ ------
+ None
+ """
+ import logging
+ from pysb.core import SelfExporter
+ from qspy.config import LOGGER_NAME
+ from qspy.utils.logging import ensure_qspy_logging
+
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ model = SelfExporter.default_model
+
+ # Record the set of initial names before entering the context
+ initials_before = set(str(init.pattern) for init in model.initials)
+ logger.info("[QSPy] Entering initials context manager")
+ try:
+ yield
+ finally:
+ # Record the set of initial names after exiting the context
+ initials_after = set(str(init.pattern) for init in model.initials)
+ added = initials_after - initials_before
+ if added:
+ # Log the names of newly added initials
+ added_initials = [
+ init for init in model.initials if str(init.pattern) in added
+ ]
+ logger.info(f"[QSPy] Initials added in context: {added_initials}")
+ else:
+ logger.info("[QSPy] No new initials added")
+
+
+@contextmanager
+def observables():
+ """
+ Context manager for defining observables in a QSPy model.
+
+ Yields
+ ------
+ None
+ """
+ import logging
+ from pysb.core import SelfExporter
+ from qspy.config import LOGGER_NAME
+ from qspy.utils.logging import ensure_qspy_logging
+
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ model = SelfExporter.default_model
+
+ # Record the set of observable names before entering the context
+ observables_before = set(obs.name for obs in model.observables)
+ logger.info("[QSPy] Entering observables context manager")
+ try:
+ yield
+ finally:
+ # Record the set of observable names after exiting the context
+ observables_after = set(init.name for init in model.observables)
+ added = observables_after - observables_before
+ if added:
+ # Log the names of newly added observables
+ added_observables = [obs for obs in model.observables if obs.name in added]
+ logger.info(f"[QSPy] Observables added in context: {added_observables}")
+ else:
+ logger.info("[QSPy] No new observables added")
+
+
+@contextmanager
+def macros():
+ """
+ Context manager for managing macros in a QSPy model.
+
+ Yields
+ ------
+ None
+ """
+ import logging
+ from pysb.core import SelfExporter
+ from qspy.config import LOGGER_NAME
+ from qspy.utils.logging import ensure_qspy_logging
+
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ model = SelfExporter.default_model
+
+ componenents_before = set(model.components.keys())
+
+ logger.info("[QSPy] Entering macros context manager")
+ try:
+ yield
+ finally:
+ components_after = set(model.components.keys())
+ added = components_after - componenents_before
+ if added:
+ # Log the names of newly added components
+ added_components = [comp for comp in model.components if comp.name in added]
+ logger.info(f"[QSPy] Components added in context: {added_components}")
+ else:
+ logger.info("[QSPy] No new components added")
+ return
diff --git a/qspy/core.py b/qspy/core.py
new file mode 100644
index 0000000..9580d23
--- /dev/null
+++ b/qspy/core.py
@@ -0,0 +1,594 @@
+"""
+QSPy Core Model Extensions and Utilities
+========================================
+
+This module extends the PySB modeling framework with QSPy-specific features for
+quantitative systems pharmacology (QSP) workflows. It provides enhanced Model and
+Monomer classes, operator overloads for semantic annotation, and utilities for
+metadata, logging, and summary/diagram generation.
+
+Classes
+-------
+Model : QSPy extension of the PySB Model class with metadata, summary, and diagram support.
+Monomer : QSPy extension of the PySB Monomer class with functional tag support.
+
+Functions
+---------
+mp_lshift : Overloads '<<' for MonomerPattern/ComplexPattern to create Initial objects.
+mp_invert : Overloads '~' for MonomerPattern/ComplexPattern to create Observables with auto-naming.
+mp_gt : Overloads '>' for MonomerPattern/ComplexPattern to create Observables with custom names.
+_make_mono_string : Utility to generate string names for MonomerPatterns.
+_make_complex_string : Utility to generate string names for ComplexPatterns.
+
+Operator Overloads
+------------------
+- MonomerPattern/ComplexPattern << value : Create Initial objects.
+- ~MonomerPattern/ComplexPattern : Create Observable objects with auto-generated names.
+- MonomerPattern/ComplexPattern > "name" : Create Observable objects with custom names.
+- Monomer @ tag : Attach a functional tag to a Monomer.
+
+Examples
+--------
+>>> from qspy.core import Model, Monomer
+>>> m = Monomer("A", ["b"])
+>>> m @ PROTEIN.LIGAND
+>>> model = Model()
+>>> model.with_units("nM", "min", "uL")
+>>> ~m(b=None)
+Observable('A_u', m(b=None))
+
+>>> m(b=None) << 100
+Initial(m(b=None), 100)
+"""
+
+from pathlib import Path
+from datetime import datetime
+import os
+import re
+from enum import Enum
+
+import pysb.units
+from pysb.units.core import *
+from pysb.core import SelfExporter, MonomerPattern, ComplexPattern
+import pysb.core
+from qspy.config import METADATA_DIR, LOGGER_NAME, SUMMARY_DIR
+from qspy.utils.logging import ensure_qspy_logging
+from qspy.functionaltags import FunctionalTag
+from qspy.utils.logging import log_event
+
+__all__ = pysb.units.core.__all__.copy()
+
+
+class Model(Model):
+ """
+ QSPy extension of the PySB Model class.
+
+ Adds QSPy-specific utilities, metadata handling, and summary/diagram generation
+ to the standard PySB Model. Supports custom units, logging, and functional tagging.
+
+ Methods
+ -------
+ with_units(concentration, time, volume)
+ Set simulation units for concentration, time, and volume.
+ component_names
+ List of component names in the model.
+ qspy_metadata
+ Dictionary of QSPy metadata for the model.
+ summarize(path, include_diagram)
+ Generate a Markdown summary of the model and optionally a diagram.
+ """
+
+ @log_event(log_args=True, static_method=True)
+ @staticmethod
+ def with_units(concentration: str = "mg/L", time: str = "h", volume: str = "L"):
+ """
+ Set simulation units for the model.
+
+ Parameters
+ ----------
+ concentration : str, optional
+ Concentration units (default "mg/L").
+ time : str, optional
+ Time units (default "h").
+ volume : str, optional
+ Volume units (default "L").
+ """
+ ensure_qspy_logging()
+ SimulationUnits(concentration, time, volume)
+ return
+
+ @property
+ def component_names(self):
+ """
+ List the names of all components in the model.
+
+ Returns
+ -------
+ list of str
+ Names of model components.
+ """
+ return [component.name for component in self.components]
+
+ @property
+ def qspy_metadata(self):
+ """
+ Return QSPy metadata dictionary for the model.
+
+ Returns
+ -------
+ dict
+ Metadata dictionary if available, else empty dict.
+ """
+ if hasattr(self, "qspy_metadata_tracker"):
+ return self.qspy_metadata_tracker.metadata
+ else:
+ return {}
+
+ @log_event(log_args=True)
+ def markdown_summary(self, path=SUMMARY_DIR, include_diagram=True):
+ """
+ Generate a Markdown summary of the model and optionally a diagram.
+
+ Parameters
+ ----------
+ path : str or Path, optional
+ Output path for the summary file (default: SUMMARY_DIR).
+ include_diagram : bool, optional
+ Whether to include a model diagram if SBMLDiagrams is available (default: True).
+
+ Returns
+ -------
+ None
+ """
+ lines = []
+ lines.append(f"# QSPy Model Summary: `{self.name}`\n")
+
+ metadata = self.qspy_metadata
+ lines.append(f"**Model name**: `{self.name}` \n")
+ lines.append(f"**Hash**: \n`{metadata.get('hash', 'N/A')}` \n")
+ lines.append(f"**Version**: {metadata.get('version', 'N/A')} \n")
+ lines.append(f"**Author**: {metadata.get('author', 'N/A')} \n")
+ lines.append(f"**Executed by**: {metadata.get('current_user', 'N/A')} \n")
+ lines.append(
+ f"**Timestamp**: {metadata.get('created_at', datetime.now().isoformat())}\n"
+ )
+
+ if include_diagram and hasattr(self, "mermaid_diagram"):
+ diagram_md = self.mermaid_diagram.markdown_block
+ lines.append("## 🖼️ Model Diagram\n")
+ lines.append(f"{diagram_md}\n")
+
+ # Core units table
+ lines.append("## Core Units\n| Quantity | Unit |")
+ lines.append("|-----------|------|")
+ units = getattr(self, "simulation_units", None)
+ if units:
+ lines.append(f"| Concentration | {units.concentration} |")
+ lines.append(f"| Time | {units.time} |")
+ lines.append(f"| Volume | {units.volume} |")
+ else:
+ lines.append("No core model units defined.")
+ lines.append(" They can added with the `Model.with_units` method.")
+
+ # Component counts table
+ lines.append("## Numbers of Model Component\n| Component Type | Count |")
+ lines.append("|---------------|-------|")
+ lines += [
+ f"| Monomers | {len(getattr(self, 'monomers', []))} |",
+ f"| Parameters | {len(getattr(self, 'parameters', []))} |",
+ f"| Expressions | {len(getattr(self, 'expressions', []))} |",
+ f"| Compartments | {len(getattr(self, 'compartments', []))} |",
+ f"| Rules | {len(getattr(self, 'rules', []))} |",
+ f"| Initial Conditions | {len(getattr(self, 'initial_conditions', []))} |",
+ f"| Observables | {len(getattr(self, 'observables', []))} |",
+ ]
+
+ # Compartments table
+ lines.append("\n## Compartments\n| Name | Size |")
+ lines.append("|------|------|")
+ lines += [
+ f"| {cpt.name} | {cpt.size.name if hasattr(cpt.size, 'name') else str(cpt.size)} |"
+ for cpt in getattr(self, "compartments", [])
+ ] or ["| _None_ | _N/A_ |"]
+
+ # Monomers table
+ lines.append("## Monomers\n| Name | Sites | States | Functional Tag |")
+ lines.append("|------|-------|--------|---------------|")
+ lines += [
+ f"| {m.name} | {m.sites} | {m.site_states} | {getattr(m.functional_tag, 'value', m.functional_tag)} |"
+ for m in self.monomers
+ ] or ["| _None_ | _N/A_ | _N/A_ | _N/A_ |"]
+
+ # Parameters table
+ lines.append("\n## Parameters\n| Name | Value | Units |")
+ lines.append("|------|--------|--------|")
+ lines += [
+ f"| {p.name} | {p.value} | {p.unit.to_string()} |" for p in self.parameters
+ ] or ["| _None_ | _N/A_ |"]
+
+ # Expressions table
+ lines.append("\n## Expressions\n| Name | Expression |")
+ lines.append("|------|------------|")
+ lines += [
+ f"| {e.name} | `{e.expr}` |" for e in getattr(self, "expressions", [])
+ ] or ["| _None_ | _N/A_ |"]
+
+ # Initial Conditions table
+ lines.append("\n## Initial Conditions\n| Species | Value | Units |")
+ lines.append("|---------|--------|--------|")
+ lines += [
+ f"| {str(ic[0])} | {ic[1].value if isinstance(ic[1], Parameter) else ic[1].get_value():.2f} | {ic[1].units.value}"
+ for ic in self.initial_conditions
+ ] or ["| _None_ | _N/A_ |"]
+
+ def _sanitize_rule_expression(expr):
+ """
+ Sanitize rule expression for Markdown rendering.
+ Replaces ' | ' with ' \| ' to avoid Markdown table formatting issues.
+ """
+ return repr(expr).replace(" | ", " \| ")
+ # Rules table
+ lines.append("\n## Rules\n| Name | Rule Expression | k_f | k_r | reversible |")
+ lines.append("|------|-----------------|-----|-----|------------|")
+ lines += [
+ f"| {r.name} | `{_sanitize_rule_expression(r.rule_expression)}` | {r.rate_forward.name} | {r.rate_reverse.name if r.rate_reverse is not None else 'None'} | {r.is_reversible} |"
+ for r in self.rules
+ ] or ["| _None_ | _N/A_ | _N/A_ | _N/A_ | _N/A_ |"]
+
+ # Observables table
+ lines.append("\n## Observables\n| Name | Reaction Pattern |")
+ lines.append("|------|------------------|")
+ lines += [
+ f"| {o.name} | `{o.reaction_pattern}` |" for o in self.observables
+ ] or ["| _None_ | _N/A_ |"]
+
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines))
+
+
+# patch the MonomerPattern object
+# so we use a special operator with pattern:
+# monomer_patter operator value
+# Applying the operator will
+# create the corresponding Initial object.
+
+
+def mp_lshift(self, value):
+ """
+ Overload the '<<' operator for MonomerPattern and ComplexPattern.
+
+ Allows creation of Initial objects using the syntax:
+ monomer_pattern << value
+
+ Parameters
+ ----------
+ value : float or Parameter
+ Initial value for the pattern.
+
+ Returns
+ -------
+ Initial
+ Initial object for the pattern.
+ """
+ return Initial(self, value)
+
+
+pysb.core.MonomerPattern.__lshift__ = mp_lshift
+pysb.core.ComplexPattern.__lshift__ = mp_lshift
+
+
+translation = str.maketrans(
+ {" ": "", "=": "", "(": "_", ")": "", "*": "", ",": "_", "'": ""}
+)
+
+
+def _make_mono_string(monopattern):
+ """
+ Generate a string representation for a MonomerPattern.
+
+ Parameters
+ ----------
+ monopattern : MonomerPattern or str
+ The monomer pattern or its string representation.
+
+ Returns
+ -------
+ str
+ String representation suitable for naming.
+ """
+ if isinstance(monopattern, MonomerPattern):
+ string_repr = repr(monopattern)
+ elif isinstance(monopattern, str):
+ string_repr = monopattern
+ else:
+ raise ValueError(
+ "Input pattern must a MonomerPattern or string representation of one"
+ )
+ # name = "place_holder"
+ # Get the text (if any) inside the parenthesis [e.g., molecule(b=None)]
+ parenthetical = re.search("\((.*)\)", string_repr).group(0).strip("(").strip(")")
+ mono = string_repr.split("(")[0]
+ comp = ""
+ # Get the compartment string if included in the pattern
+ if "**" in string_repr:
+ comp = string_repr.split("**")[1].replace(" ", "")
+ if parenthetical:
+ # Check for bond and state info [(b=None, state='p')]
+ # NOTE - does not account for multiple bonding sites
+ if "," in parenthetical:
+ bond, state = parenthetical.split(",")
+ bond = bond.split("=")[1]
+ state = state.split("'")[1]
+ # print(bond, state)
+ if bond == "None":
+ if comp:
+ # None bond w/ state and compartment
+ name = f"{mono}_{state}_{comp}"
+
+ else:
+ # None bond with state - no compartment
+ name = f"{mono}_{state}"
+ else:
+ # Include bond, state, and compartment
+ name = f"{mono}_{bond}_{state}_{comp}"
+
+ else:
+ # Get bond or state info when only one is present (not both) [e.g., (b=None) or (state='u')]
+ bond_or_state = parenthetical.split("=")[1].replace("'", "")
+ # print(bond_or_state)
+ if comp:
+ # Bond/state and compartment
+ name = f"{mono}_{bond_or_state}_{comp}"
+ else:
+ # Bond/state - no compartment
+ name = f"{mono}_{bond_or_state}"
+ else:
+ # No bond or state info [e.g., empty parenthesis ()]
+ if comp:
+ # With compartment
+ name = f"{mono}_{comp}"
+ else:
+ # No compartment
+ name = f"{mono}"
+ return name
+
+
+def _make_complex_string(complexpattern):
+ """
+ Generate a string representation for a ComplexPattern.
+
+ Parameters
+ ----------
+ complexpattern : ComplexPattern
+ The complex pattern.
+
+ Returns
+ -------
+ str
+ String representation suitable for naming.
+ """
+ string_repr = repr(complexpattern)
+ # Split at the bond operator '%' to
+ # get the left and right-hand monomer patterns.
+ mono_left, mono_right = string_repr.split("%")
+ # Process each monomer pattern:
+ name_left = _make_mono_string(mono_left)
+ name_right = _make_mono_string(mono_right)
+ name = f"{name_left}_{name_right}"
+ return name
+
+
+# Make observable definition availabe with
+# the iversion '~' prefix operator and an
+# auto generated name based on the monomer or complex
+# pattern string:
+# ~pattern , e.g.:
+# ~molecA() # name='molecA'
+def mp_invert(self):
+ """
+ Overload the '~' operator for MonomerPattern and ComplexPattern.
+
+ Allows creation of Observable objects using the syntax:
+ ~pattern
+
+ Returns
+ -------
+ Observable
+ Observable object with an auto-generated name.
+ """
+
+ if isinstance(self, MonomerPattern):
+ name = _make_mono_string(self)
+
+ elif isinstance(self, ComplexPattern):
+ name = _make_complex_string(self)
+
+ # name = 'gooo'
+ return Observable(name, self)
+
+
+pysb.core.MonomerPattern.__invert__ = mp_invert
+pysb.core.ComplexPattern.__invert__ = mp_invert
+
+
+# Make observable definition availabe with
+# the greater than sign '>' operator:
+# pattern > "observable_name", e.g.:
+# molecA() > "A"
+def mp_gt(self, other):
+ """
+ Overload the '>' operator for MonomerPattern and ComplexPattern.
+
+ Allows creation of Observable objects with a custom name using the syntax:
+ pattern > "observable_name"
+
+ Parameters
+ ----------
+ other : str
+ Name for the observable.
+
+ Returns
+ -------
+ Observable
+ Observable object with the specified name.
+
+ Raises
+ ------
+ ValueError
+ If the provided name is not a string.
+ """
+ if not isinstance(other, str):
+ raise ValueError("Observable name should be a string")
+ else:
+ return Observable(other, self)
+
+
+pysb.core.MonomerPattern.__gt__ = mp_gt
+pysb.core.ComplexPattern.__gt__ = mp_gt
+
+
+class Monomer(Monomer):
+ """
+ QSPy extension of the PySB Monomer class.
+
+ Adds support for functional tags and operator overloading for semantic annotation.
+
+ Methods
+ -------
+ __matmul__(other)
+ Attach a functional tag to the monomer using the '@' operator.
+ __imatmul__(other)
+ Attach a functional tag to the monomer in-place using the '@=' operator.
+ __repr__()
+ String representation including the functional tag if present.
+ """
+
+ def __init__(self, *args, **kwargs):
+ """
+ Initialize a Monomer with optional functional tag.
+
+ Parameters
+ ----------
+ *args, **kwargs
+ Arguments passed to the PySB Monomer constructor.
+ """
+ self.functional_tag = FunctionalTag(
+ "None", "None"
+ ) # Default to no functional tag
+ super().__init__(*args, **kwargs)
+ return
+
+ def __matmul__(self, other: Enum):
+ """
+ Attach a functional tag to the monomer using the '@' operator.
+
+ Parameters
+ ----------
+ other : Enum
+ Enum member representing the functional tag.
+
+ Returns
+ -------
+ Monomer
+ The monomer instance with the functional tag set.
+ """
+ if isinstance(other, Enum):
+ ftag_str = other.value
+ ftag = FunctionalTag(*FunctionalTag.parse(ftag_str))
+ setattr(self, "functional_tag", ftag)
+ return self
+
+ def __imatmul__(self, other: Enum):
+ """
+ Attach a functional tag to the monomer in-place using the '@=' operator.
+
+ Parameters
+ ----------
+ other : Enum
+ Enum member representing the functional tag.
+
+ Returns
+ -------
+ Monomer
+ The monomer instance with the functional tag set.
+ """
+ if isinstance(other, Enum):
+ ftag_str = other.value
+ ftag = FunctionalTag(*FunctionalTag.parse(ftag_str))
+ setattr(self, "functional_tag", ftag)
+ return self
+
+ def __repr__(self):
+ """
+ Return a string representation of the monomer, including the functional tag if present.
+
+ Returns
+ -------
+ str
+ String representation of the monomer.
+ """
+ if self.functional_tag is None:
+ return super().__repr__()
+ else:
+ base_repr = super().__repr__()
+ return f"{base_repr} @ {self.functional_tag.value}"
+
+ def __pow__(self, compartment: Compartment):
+ """
+ Overload the '**' operator to allow monomers to be passed with a compartment.
+
+ This is a shortcut for creating a MonomerPattern with a compartment:
+ i.e., `monomer ** compartment` is equivalent to `monomer() ** compartment`.
+
+
+ Parameters
+ ----------
+ compartment : Compartment
+ The compartment in which the monomer pattern resides.
+
+ Returns
+ -------
+ MonomerPattern
+ A MonomerPattern from calling the monomer with the compartment.
+ """
+ if not isinstance(compartment, Compartment):
+ raise TypeError("Compartment must be an instance of Compartment")
+ return self() ** compartment
+
+# class Compartment(Compartment):
+# """
+# QSPy extension of the PySB Compartment class.
+
+# This class allows for the creation of compartments with specific names and sizes.
+# It can be used to define compartments in a QSPy model.
+
+# Parameters
+# ----------
+# name : str
+# Name of the compartment.
+# size : float or Parameter, optional
+# Size of the compartment (default is 1.0).
+# """
+
+# def __init__(self, name: str, size: float | Parameter = 1.0):
+# super().__init__(name, size)
+
+# def __contains__(self, other: MonomerPattern | ComplexPattern | Monomer):
+# """
+# Check if a monomer pattern or complex pattern is contained within this compartment.
+
+# Parameters
+# ----------
+# other : MonomerPattern or ComplexPattern
+# The pattern to check for containment.
+
+# Returns
+# -------
+# bool
+# True if the pattern is contained in this compartment, False otherwise.
+# """
+# if not isinstance(other, (MonomerPattern, ComplexPattern)):
+# raise TypeError("Other must be a MonomerPattern, ComplexPattern, or Monomer")
+# return other ** self
\ No newline at end of file
diff --git a/qspy/examples/LR_comp.py b/qspy/examples/LR_comp.py
new file mode 100644
index 0000000..a4da43b
--- /dev/null
+++ b/qspy/examples/LR_comp.py
@@ -0,0 +1,70 @@
+# QSPy Model Specification Example
+# =========================
+# This document provides an example of how to specify a model in QSPy, including the use of functional monomers, metadata tracking, and validation tools.
+# This example demonstrates a simple ligand-receptor binding model with metadata tracking and validation.
+# Adapted from the LR_comp.bngl model at https://github.com/RuleWorld/BNGTutorial/blob/master/CBNGL/LR_comp.bngl
+
+from qspy import *
+from pysb.units import set_molecule_volume
+
+Model().with_units(concentration="molecules", time="s", volume="um**3")
+v_cell = 1000 # Typical eukaryotic cell volume in um^3
+v_ec = 1000 * v_cell # Volume of extracellular space around each cell (1/cell density)
+# Required for molecules units and conversions between molar concentrations and molecules
+set_molecule_volume(v_cell, "um**3") # Set the default molecule volume for the model
+
+with parameters():
+ Vcell = (v_cell, "um**3") # Typical eukaryotic cell volume ~ 1000 um^3
+ Vec = (
+ v_ec,
+ "um**3",
+ ) # Volume of extracellular space around each cell (1/cell density)
+ d_pm = (0.01, "um") # Effective thickness of the plasma membrane (10 nm)
+ Acell = (1000, "um**2") # Approximate area of PM
+ R0 = (10000, "molecules") # number of receptor molecules per cell
+ kp1 = (1e6, "1/(M*s)") # Forward binding rate constant for L-R
+ km1 = (0.01, "1/s") # Reverse binding rate constant for L-R
+ L0 = (1e-8, "M") # Ligand concentration - molar
+
+with expressions():
+ Vpm = Acell * d_pm # Effective volume of PM
+
+# Define compartments with sizes and dimensions
+# Need to use the Compartment class directly (instead of compartments context)
+# to define compartments so we can set the parent compartments correctly
+Compartment("EC", size=Vec, dimension=3)
+Compartment(
+ "PM", size=Vpm, dimension=2, parent=EC
+) # Plasma membrane is part of the extracellular space
+Compartment(
+ "CP", size=Vcell, dimension=3, parent=PM
+) # Cytoplasmic compartment is also part of the plasma membrane
+
+with monomers():
+ L = (["r"], None, PROTEIN.LIGAND) # Ligand with one binding site
+ R = (["l"], None, PROTEIN.RECEPTOR) # Receptor with one binding site
+
+with initials():
+ L(r=None) ** EC << L0 # Initial ligand concentration in extracellular space
+ R(l=None) ** PM << R0 # Initial receptor concentration in plasma membrane
+
+with observables():
+ R(l=None) ** PM > "FreeR"
+ R(l=ANY) ** PM > "Bound"
+ L(r=ANY) ** EC > "test" # Testing if any bound ligands in EC
+
+with rules():
+ # Reversible binding reaction between ligand and receptor
+ binding = (
+ L(r=None) ** EC + R(l=None) ** PM | L(r=1) ** EC % R(l=1) ** PM,
+ kp1,
+ km1,
+ )
+
+ModelMetadataTracker(
+ version="0.1.0",
+ author="QSPy Example",
+)
+ModelMermaidDiagrammer()
+# Validate the model
+ModelChecker()
diff --git a/qspy/examples/__init__.py b/qspy/examples/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/qspy/experimental/__init__.py b/qspy/experimental/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/qspy/experimental/functional_monomers/__init__.py b/qspy/experimental/functional_monomers/__init__.py
new file mode 100644
index 0000000..a8617c5
--- /dev/null
+++ b/qspy/experimental/functional_monomers/__init__.py
@@ -0,0 +1,6 @@
+"""
+QSPy experimental functional monomers subpackage.
+
+Provides base classes, mixins, and macros for building and manipulating functional monomers,
+including support for binding, synthesis, degradation, and protein-specific behaviors.
+"""
\ No newline at end of file
diff --git a/qspy/experimental/functional_monomers/base.py b/qspy/experimental/functional_monomers/base.py
new file mode 100644
index 0000000..8d036d2
--- /dev/null
+++ b/qspy/experimental/functional_monomers/base.py
@@ -0,0 +1,202 @@
+"""
+FunctionalMonomer base classes and mixins for QSPy experimental functional monomer API.
+
+Provides:
+- FunctionalMonomer: base class for monomers with functional tags and base states.
+- BindMixin: mixin for binding macro methods.
+- SynthesizeMixin: mixin for synthesis macro methods.
+- DegradeMixin: mixin for degradation macro methods.
+"""
+
+from abc import ABC, abstractmethod
+from ..core import Monomer, Parameter, Compartment, MonomerPattern
+from pysb.macros import bind, synthesize, catalyze, degrade, catalyze_state
+from pysb.core import ComponentSet
+from pysb.pkpd.macros import _check_for_monomer
+
+
+class FunctionalMonomer(Monomer):
+ """
+ Base class for functional monomers in QSPy.
+
+ Adds support for binding sites, site states, functional tags, and a base state.
+ """
+
+ _sites = None
+ _site_states = None
+ _functional_tag = None
+ _base_state = dict()
+
+ def __init__(self, name: str):
+ """
+ Initialize a FunctionalMonomer.
+
+ Parameters
+ ----------
+ name : str
+ Name of the monomer.
+ """
+ super(FunctionalMonomer, self).__init__(name, self._sites, self._site_states)
+ self @= self._functional_tag
+ return
+
+ @property
+ def binding_sites(self) -> list:
+ """
+ List of binding sites for this monomer.
+
+ Returns
+ -------
+ list
+ List of binding site names.
+ """
+ return self._sites
+
+ @property
+ def states(self) -> dict:
+ """
+ Dictionary of site states for this monomer.
+
+ Returns
+ -------
+ dict
+ Dictionary mapping site names to possible states.
+ """
+ return self._site_states
+
+ @property
+ def base_state(self) -> dict:
+ """
+ Dictionary of base state values for this monomer.
+
+ Returns
+ -------
+ dict
+ Dictionary of base state assignments.
+ """
+ return self._base_state
+
+
+class BindMixin:
+ """
+ Mixin class providing a binds() method for binding reactions.
+ """
+
+ def binds(
+ self,
+ site: str,
+ other: Monomer | MonomerPattern | FunctionalMonomer,
+ other_site: str,
+ k_f: float | Parameter,
+ k_r: float | Parameter,
+ compartment: None | Compartment = None,
+ ) -> ComponentSet:
+ """
+ Create a reversible binding reaction between this monomer and another.
+
+ Parameters
+ ----------
+ site : str
+ Binding site on this monomer.
+ other : Monomer, MonomerPattern, or FunctionalMonomer
+ The other monomer or pattern to bind.
+ other_site : str
+ Binding site on the other monomer.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the binding reaction.
+ """
+ if compartment is None:
+ return bind(self, site, other, other_site, klist=[k_f, k_r])
+ else:
+ return bind(
+ _check_for_monomer(self, compartment),
+ site,
+ _check_for_monomer(other, compartment),
+ other_site,
+ klist=[k_f, k_r],
+ )
+
+
+class SynthesizeMixin:
+ """
+ Mixin class providing a synthesized() method for synthesis reactions.
+ """
+
+ def synthesized(
+ self, k_syn: float | Parameter, compartment: None | Compartment = None
+ ) -> ComponentSet:
+ """
+ Create a synthesis reaction for this monomer.
+
+ Parameters
+ ----------
+ k_syn : float or Parameter
+ Synthesis rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the synthesis reaction.
+ """
+ if compartment is None:
+ return synthesize(
+ self(**self.base_state),
+ k_syn,
+ )
+ else:
+ return synthesize(
+ _check_for_monomer(self(**self.base_state), compartment),
+ k_syn,
+ )
+
+
+class DegradeMixin:
+ """
+ Mixin class providing a degraded() method for degradation reactions.
+ """
+
+ def degraded(
+ self,
+ state: dict,
+ k_deg: float | Parameter,
+ compartment: None | Compartment = None,
+ ):
+ """
+ Create a degradation reaction for this monomer in a given state.
+
+ Parameters
+ ----------
+ state : dict
+ State assignment for the monomer.
+ k_deg : float or Parameter
+ Degradation rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the degradation reaction.
+ """
+ if compartment is None:
+ return degrade(
+ self(**state),
+ k_deg,
+ )
+ else:
+ return degrade(
+ _check_for_monomer(self(**state), compartment),
+ k_deg,
+ )
+
diff --git a/qspy/experimental/functional_monomers/macros.py b/qspy/experimental/functional_monomers/macros.py
new file mode 100644
index 0000000..3f3de24
--- /dev/null
+++ b/qspy/experimental/functional_monomers/macros.py
@@ -0,0 +1,96 @@
+"""
+Functional monomer macros for QSPy experimental API.
+
+Provides:
+- activate_concerted: macro for concerted activation of a receptor by a ligand.
+"""
+
+from pysb import Monomer, Parameter, Expression, Observable, Compartment, Initial
+from pysb.core import ComponentSet, as_complex_pattern, MonomerPattern, ComplexPattern
+from pysb.macros import _macro_rule, _verify_sites, _complex_pattern_label
+from pysb.pkpd.macros import _check_for_monomer
+
+
+def activate_concerted(
+ ligand: Monomer | MonomerPattern,
+ l_site: str,
+ receptor: Monomer | MonomerPattern,
+ r_site: str,
+ inactive_state: dict,
+ active_state: dict,
+ k_list: list,
+ compartment: None | Compartment = None,
+):
+ """
+ Generate a concerted activation reaction for a receptor by a ligand.
+
+ This macro creates a reversible binding rule where a ligand binds to a receptor,
+ and the receptor transitions from an inactive state to an active state as part of the binding event.
+
+ Parameters
+ ----------
+ ligand : Monomer or MonomerPattern
+ The ligand species or pattern.
+ l_site : str
+ The binding site on the ligand.
+ receptor : Monomer or MonomerPattern
+ The receptor species or pattern.
+ r_site : str
+ The binding site on the receptor.
+ inactive_state : dict
+ Dictionary specifying the inactive state of the receptor.
+ active_state : dict
+ Dictionary specifying the active state of the receptor.
+ k_list : list
+ List of rate constants [k_f, k_r] for forward and reverse reactions.
+ compartment : Compartment or None, optional
+ The compartment in which the reaction occurs (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ The generated components, including the reversible activation Rule.
+
+ Examples
+ --------
+ Concerted activation of a receptor by a ligand::
+
+ Model()
+ Monomer('Ligand', ['b'])
+ Monomer('Receptor', ['b', 'state'], {'state': ['inactive', 'active']})
+ activate_concerted(
+ Ligand, 'b', Receptor, 'b',
+ {'state': 'inactive'}, {'state': 'active'},
+ [1e-3, 1e-3]
+ )
+
+ """
+ _verify_sites(ligand, l_site)
+ _verify_sites(receptor, [r_site] + list(active_state.keys()))
+ def activate_concerted_name_func(rule_expression):
+ cps = rule_expression.reactant_pattern.complex_patterns
+ if compartment is not None:
+ comp_name = compartment.name
+ return "_".join(_complex_pattern_label(cp) for cp in cps).join(
+ ["_", comp_name]
+ )
+ else:
+ return "_".join(_complex_pattern_label(cp) for cp in cps)
+
+ s1_free = ligand(**{l_site: None})
+ s1_bound = ligand(**{l_site: 1})
+ s2_i = receptor(**{r_site: None}.update(inactive_state))
+ s2_a = receptor(**{r_site: 1}.update(active_state))
+ if compartment is not None:
+ s1_free = _check_for_monomer(s1_free, compartment)
+ s1_bound = _check_for_monomer(s1_bound, compartment)
+ s2_i = _check_for_monomer(s2_i, compartment)
+ s2_a = _check_for_monomer(s2_a, compartment)
+
+ return _macro_rule(
+ "activate_concerted",
+ s1_free + s2_i | s1_bound % s2_a,
+ k_list,
+ ["k_f", "k_r"],
+ name_func=activate_concerted_name_func,
+ )
diff --git a/qspy/experimental/functional_monomers/protein.py b/qspy/experimental/functional_monomers/protein.py
new file mode 100644
index 0000000..28e789e
--- /dev/null
+++ b/qspy/experimental/functional_monomers/protein.py
@@ -0,0 +1,319 @@
+"""
+Functional monomer protein classes and mixins for QSPy experimental API.
+
+Provides:
+- TurnoverMixin: mixin for synthesis and degradation reactions.
+- Ligand: class for ligand monomers with binding functionality.
+- Receptor: class for receptor monomers with orthosteric/allosteric binding and activation.
+"""
+
+from ..core import Parameter, Compartment
+from ..functionaltags import *
+from .base import FunctionalMonomer, BindMixin, SynthesizeMixin, DegradeMixin
+from .macros import activate_concerted
+from pysb.core import ComponentSet
+
+
+class TurnoverMixin(DegradeMixin, SynthesizeMixin):
+ """
+ Mixin class providing a turnover() method for synthesis and degradation reactions.
+ """
+
+ def turnover(
+ self,
+ k_syn: float | Parameter,
+ k_deg: float | Parameter,
+ compartment: None | Compartment = None,
+ ) -> ComponentSet:
+ """
+ Create synthesis and degradation reactions for this monomer.
+
+ Parameters
+ ----------
+ k_syn : float or Parameter
+ Synthesis rate constant.
+ k_deg : float or Parameter
+ Degradation rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reactions (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ Combined PySB ComponentSet for synthesis and degradation.
+ """
+ components_syn = self.synthesized(k_syn, compartment=compartment)
+ components_deg = self.degraded({}, k_deg, compartment=compartment)
+ return components_syn | components_deg
+
+
+class Ligand(BindMixin, FunctionalMonomer):
+ """
+ Class representing a ligand monomer with binding functionality.
+ """
+
+ _sites = ["b"]
+ _functional_tag = PROTEIN.LIGAND
+ _base_state = {"b": None}
+
+ @property
+ def binding_site(self):
+ """
+ Return the binding site for this ligand.
+
+ Returns
+ -------
+ str
+ The name of the binding site.
+ """
+ return self._sites[0]
+
+ def binds_to(
+ self,
+ receptor: "Receptor",
+ r_site: str,
+ k_f: float | Parameter,
+ k_r: float | Parameter,
+ compartment: None | Compartment = None,
+ ):
+ """
+ Create a reversible binding reaction between this ligand and a receptor.
+
+ Parameters
+ ----------
+ receptor : Receptor
+ The receptor to bind.
+ r_site : str
+ The binding site on the receptor.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the binding reaction.
+ """
+ return self.binds(
+ self.binding_site, receptor, r_site, k_f, k_r, compartment=compartment
+ )
+
+ def concertedly_activates(self, receptor, k_f, k_r, compartment=None):
+ """
+ Create a concerted activation reaction for a receptor by this ligand.
+
+ Parameters
+ ----------
+ receptor : Receptor
+ The receptor to activate.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the concerted activation reaction.
+ """
+ return receptor.concertedly_activated_by(self, k_f, k_r, compartment)
+
+
+class Receptor(BindMixin, TurnoverMixin, FunctionalMonomer):
+ """
+ Class representing a receptor monomer with orthosteric/allosteric binding and activation.
+ """
+
+ _sites = ["b_ortho", "b_allo", "active"]
+ _site_states = {"active": [False, True]}
+ _functional_tag = PROTEIN.RECEPTOR
+ _base_state = {"b_ortho": None, "b_allo": None, "active": False}
+ _inactive_state = {"active": False}
+ _active_state = {"active": True}
+
+ @property
+ def binding_sites(self):
+ """
+ Return the orthosteric and allosteric binding sites.
+
+ Returns
+ -------
+ list
+ List of binding site names.
+ """
+ return self._sites[:2]
+
+ @property
+ def orthosteric_site(self):
+ """
+ Return the orthosteric binding site.
+
+ Returns
+ -------
+ str
+ Name of the orthosteric site.
+ """
+ return self._sites[0]
+
+ @property
+ def allosteric_site(self):
+ """
+ Return the allosteric binding site.
+
+ Returns
+ -------
+ str
+ Name of the allosteric site.
+ """
+ return self._sites[1]
+
+ @property
+ def inactive(self):
+ """
+ Return the inactive state dictionary.
+
+ Returns
+ -------
+ dict
+ Dictionary representing the inactive state.
+ """
+ return self._inactive_state
+
+ @property
+ def active(self):
+ """
+ Return the active state dictionary.
+
+ Returns
+ -------
+ dict
+ Dictionary representing the active state.
+ """
+ return self._active_state
+
+ def _binds_orthosteric(
+ self,
+ ligand: Ligand,
+ k_f: float | Parameter,
+ k_r: float | Parameter,
+ compartment: None | Compartment = None,
+ ):
+ """
+ Create a reversible binding reaction at the orthosteric site.
+
+ Parameters
+ ----------
+ ligand : Ligand
+ The ligand to bind.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the binding reaction.
+ """
+ return self.binds(
+ self.orthosteric_site,
+ ligand,
+ ligand.binding_site,
+ k_f,
+ k_r,
+ compartment=compartment,
+ )
+
+ def _binds_allosteric(self, ligand: Ligand, k_f, k_r, compartment=None):
+ """
+ Create a reversible binding reaction at the allosteric site.
+
+ Parameters
+ ----------
+ ligand : Ligand
+ The ligand to bind.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the binding reaction.
+ """
+ return self.binds(
+ self.allosteric_site,
+ ligand,
+ ligand.binding_site,
+ k_f,
+ k_r,
+ compartment=compartment,
+ )
+
+ def bound_by(self, ligand, k_f, k_r, location="orthosteric", compartment=None):
+ """
+ Create a reversible binding reaction at the specified site.
+
+ Parameters
+ ----------
+ ligand : Ligand
+ The ligand to bind.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ location : str, optional
+ "orthosteric" or "allosteric" (default: "orthosteric").
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the binding reaction.
+ """
+ if location == "orthosteric":
+ return self._binds_orthosteric(ligand, k_f, k_r, compartment=compartment)
+ elif location == "allosteric":
+ return self._binds_allosteric(ligand, k_f, k_r, compartment=compartment)
+
+ def concertedly_activated_by(self, ligand: Ligand, k_f, k_r, compartment=None):
+ """
+ Create a concerted activation reaction for this receptor by a ligand.
+
+ Parameters
+ ----------
+ ligand : Ligand
+ The ligand to activate this receptor.
+ k_f : float or Parameter
+ Forward rate constant.
+ k_r : float or Parameter
+ Reverse rate constant.
+ compartment : Compartment or None, optional
+ Compartment for the reaction (default: None).
+
+ Returns
+ -------
+ ComponentSet
+ PySB ComponentSet for the concerted activation reaction.
+ """
+ return activate_concerted(
+ ligand,
+ ligand.binding_site,
+ self,
+ self.orthosteric_site,
+ self.inactive,
+ self.active,
+ [k_f, k_r],
+ compartment=compartment,
+ )
diff --git a/qspy/experimental/infix_macros.py b/qspy/experimental/infix_macros.py
new file mode 100644
index 0000000..7209ae3
--- /dev/null
+++ b/qspy/experimental/infix_macros.py
@@ -0,0 +1,399 @@
+"""
+QSPy experimental infix macros for expressive model syntax.
+
+Provides infix-style macro objects for binding, elimination, and equilibrium interactions:
+- binds: infix macro for reversible binding reactions
+- eliminated: infix macro for elimination reactions
+- equilibrates: infix macro for reversible state transitions
+
+These macros enable expressive model code such as:
+ species *binds* target & (k_f, k_r)
+ species *eliminated* compartment & k_deg
+ state1 *equilibrates* state2 & (k_f, k_r)
+"""
+
+from abc import ABC, abstractmethod
+
+from qspy.core import Monomer, Parameter
+from pysb.core import MonomerPattern, ComplexPattern, Compartment, ComponentSet
+from pysb.macros import bind, equilibrate
+from pysb.pkpd.macros import eliminate
+
+
+class InfixMacro(ABC):
+ """
+ Abstract base class for infix-style macros in QSPy.
+
+ This class provides a structure for creating infix-style macros that can be used
+ in a way that more closely resembles specifying a biological action.
+ Adapted from `Infix` class example at: https://discuss.python.org/t/infix-function-in-python/41820/2
+ """
+
+ def __init__(self, lhs=None, rhs=None):
+ """
+ Initialize the infix macro with optional left and right sides.
+
+ Parameters
+ ----------
+ lhs : any, optional
+ The left-hand side of the infix operation.
+ rhs : any, optional
+ The right-hand side of the infix operation.
+ """
+ self.lhs = lhs
+ self.rhs = rhs
+
+ @abstractmethod
+ def execute_macro(self, lhs, rhs, at):
+ """
+ Abstract method to execute the macro logic.
+
+ Parameters
+ ----------
+ lhs : any
+ The left-hand side operand.
+ rhs : any
+ The right-hand side operand.
+ at : any
+ Additional argument for the macro.
+
+ Returns
+ -------
+ any
+ The result of the macro operation.
+ """
+ pass
+
+ def __rmul__(self, lhs):
+ """
+ Capture the left-hand side operand for the infix macro.
+
+ Parameters
+ ----------
+ lhs : any
+ The left-hand side operand.
+
+ Returns
+ -------
+ InfixMacro
+ The same instance with lhs set.
+ """
+ self.lhs = lhs
+ return self
+
+ def __mul__(self, rhs):
+ """
+ Capture the right-hand side operand for the infix macro.
+
+ Parameters
+ ----------
+ rhs : any
+ The right-hand side operand.
+
+ Returns
+ -------
+ InfixMacro
+ The same instance with rhs set.
+ """
+ self.rhs = rhs
+ return self
+
+ def __and__(self, at):
+ """
+ Execute the macro logic using the & operator.
+
+ Parameters
+ ----------
+ at : any
+ Additional argument for the macro.
+
+ Returns
+ -------
+ any
+ The result of the macro operation.
+ """
+ return self.execute_macro(self.lhs, self.rhs, at)
+
+ @staticmethod
+ def parse_pattern(pattern: MonomerPattern | ComplexPattern):
+ """
+ Parse the monomer pattern to extract relevant information.
+
+ Parameters
+ ----------
+ pattern : MonomerPattern or ComplexPattern
+ The pattern to parse.
+
+ Returns
+ -------
+ tuple
+ A tuple containing the monomer, binding site, state, and compartment.
+ """
+ mono = pattern.monomer
+ bsite = next(
+ (key for key, value in pattern.site_conditions.items() if value is None),
+ None,
+ )
+ state = {
+ key: value for key, value in pattern.site_conditions.items() if key != bsite
+ }
+ compartment = pattern.compartment
+ return mono, bsite, state, compartment
+
+
+class _Binds(InfixMacro):
+ def __rmul__(self, lhs: MonomerPattern | ComplexPattern):
+ """
+ Capture the left-hand side monomer or complex pattern.
+
+ Parameters
+ ----------
+ other : MonomerPattern or ComplexPattern
+ The left-hand side of the infix operation.
+
+ Returns
+ -------
+ binds
+ Returns self to allow chaining with the right-hand side.
+ """
+ if not isinstance(lhs, (MonomerPattern, ComplexPattern)):
+ return NotImplemented
+ self.lhs = lhs
+ return self # Return an instance to allow chaining with the right-hand side
+
+ def __mul__(self, rhs: MonomerPattern | ComplexPattern):
+ """
+ Capture the right-hand side monomer or complex pattern and create the binding rule.
+
+ Parameters
+ ----------
+ other : MonomerPattern or ComplexPattern
+ The right-hand side of the infix operation.
+
+ Returns
+ -------
+ ComponentSet
+ The PySB ComponentSet from the corresponding call to the bind macro.
+ """
+ if not isinstance(rhs, (MonomerPattern, ComplexPattern)):
+ return NotImplemented
+ self.rhs = rhs
+ return self
+
+ def __matmul__(self, at: tuple[str] | list[str]):
+ """
+ Capture the additional argument for the macro using the @ operator.
+
+ Parameters
+ ----------
+ at : tuple[str] | list[str]
+ The additional argument for the macro.
+
+ Returns
+ -------
+ InfixMacro
+ The same instance with at set.
+ """
+ lb, rb = at
+ lhs_sites = {lb: None}
+ lhs_sites |= self.lhs.site_conditions
+ rhs_sites = {rb: None}
+ rhs_sites |= self.rhs.site_conditions
+ self.lhs = self.lhs.monomer(**lhs_sites) ** self.lhs.compartment
+ self.rhs = self.rhs.monomer(**rhs_sites) ** self.rhs.compartment
+ return self
+
+ def __and__(
+ self, _and: tuple[float | Parameter] | list[float | Parameter]
+ ) -> ComponentSet:
+ """
+ Execute the binding macro using the & operator.
+
+ Parameters
+ ----------
+ at : tuple[float] | tuple[Parameter]
+ Additional arguments for the macro.
+
+ Returns
+ -------
+ ComponentSet
+ The PySB ComponentSet from the corresponding call to the bind macro.
+ """
+ if self.lhs is None or self.rhs is None:
+ return NotImplemented
+ if not isinstance(_and, (tuple, list)):
+ return NotImplemented
+ if not len(_and) == 2:
+ raise ValueError("Must be a two-element tuple of form (k_f, k_r)")
+ return self.execute_macro(self.lhs, self.rhs, [*_and])
+
+ def execute_macro(
+ self,
+ lhs: MonomerPattern | ComplexPattern,
+ rhs: MonomerPattern | ComplexPattern,
+ at: tuple[Parameter, Parameter],
+ ) -> ComponentSet:
+ """
+ Execute the binding macro logic.
+
+ Parameters
+ ----------
+ lhs : MonomerPattern or ComplexPattern
+ The left-hand side of the infix operation.
+ rhs : MonomerPattern or ComplexPattern
+ The right-hand side of the infix operation.
+ at : tuple[Parameter, Parameter]
+ A tuple containing the forward and reverse rate constants.
+
+ Returns
+ -------
+ ComponentSet
+ The PySB ComponentSet from the corresponding call to the bind macro.
+ """
+ k_f, k_r = at
+ left_mono, left_bsite, left_state, left_compartment = self.parse_pattern(lhs)
+ right_mono, right_bsite, right_state, right_compartment = self.parse_pattern(
+ rhs
+ )
+
+ return bind(
+ left_mono(**left_state) ** left_compartment,
+ left_bsite,
+ right_mono(**right_state) ** right_compartment,
+ right_bsite,
+ [k_f, k_r],
+ )
+
+
+class _Eliminated(InfixMacro):
+ def __rmul__(self, lhs: MonomerPattern | ComplexPattern | Monomer):
+ """
+ Capture the left-hand side monomer or complex pattern for the elimination macro.
+
+ Parameters
+ ----------
+ lhs : MonomerPattern or ComplexPattern
+ The species to be eliminated from a compartment.
+
+ Returns
+ -------
+ _EliminatedFrom
+ The same instance with lhs set, allowing chaining with the right-hand side.
+ """
+ if not isinstance(lhs, (MonomerPattern, ComplexPattern, Monomer)):
+ return NotImplemented
+ self.lhs = lhs
+ return self # Return an instance to allow chaining with the right-hand side
+
+ def __mul__(self, rhs: Compartment):
+ """
+ Capture the right-hand side compartment for the elimination macro.
+
+ Parameters
+ ----------
+ rhs : Compartment
+ The compartment from which the species will be eliminated.
+
+ Returns
+ -------
+ _EliminatedFrom
+ The same instance with rhs set, allowing chaining with the @ operator.
+ """
+ if not isinstance(rhs, Compartment):
+ return NotImplemented
+ self.rhs = rhs
+ return self
+
+ def execute_macro(
+ self,
+ lhs: MonomerPattern | ComplexPattern,
+ rhs: Compartment,
+ at: Parameter | float,
+ ) -> ComponentSet:
+ """
+ Execute the elimination macro logic.
+
+ Parameters
+ ----------
+ lhs : MonomerPattern or ComplexPattern
+ The species to be eliminated.
+ rhs : Compartment
+ The compartment from which the species will be eliminated.
+ at : Parameter or float
+ The elimination rate constant.
+
+ Returns
+ -------
+ ComponentSet
+ The PySB ComponentSet from the corresponding call to the eliminate macro.
+ """
+ return eliminate(lhs, rhs, at)
+
+ def __and__(self, at: Parameter | float) -> ComponentSet:
+ """
+ Execute the elimination macro using the & operator.
+
+ Parameters
+ ----------
+ at : Parameter or float
+ The elimination rate constant.
+
+ Returns
+ -------
+ ComponentSet
+ The PySB ComponentSet from the corresponding call to the eliminate macro.
+ """
+ if self.lhs is None or self.rhs is None:
+ return NotImplemented
+
+ return self.execute_macro(self.lhs, self.rhs, at)
+
+
+
+class _Equilibrates(_Binds):
+ """
+ A class to represent an equilibrating two states.
+
+ """
+
+ def __matmul__(self, at):
+ return self
+
+ def execute_macro(
+ self,
+ lhs: MonomerPattern | ComplexPattern,
+ rhs: MonomerPattern | ComplexPattern,
+ _and: tuple[Parameter, Parameter],
+ ) -> ComponentSet:
+ """
+ Execute the equilibrate macro logic.
+
+ Parameters
+ ----------
+ lhs : MonomerPattern or ComplexPattern
+ The left-hand side of the infix operation.
+ rhs : MonomerPattern or ComplexPattern
+ The right-hand side of the infix operation.
+ _and : tuple[Parameter, Parameter]
+ A tuple containing the forward and reverse rate constants.
+
+ Returns
+ -------
+ ComponentSet
+ The PySB ComponentSet from the corresponding call to the equilibrate macro.
+ """
+ k_f, k_r = _and
+ return equilibrate(lhs, rhs, [k_f, k_r])
+
+
+
+# Create instances of the infix macros
+binds = _Binds() # Create an instance of the binds infix macro
+eliminated = _Eliminated() # Create an instance of the eliminated infix macro
+equilibrates = _Equilibrates() # Create an instance of the equilibrates infix macro
+
+__all__ = [
+ "binds", # The binds infix macro for binding interactions
+ "eliminated", # The eliminated infix macro for elimination interactions
+ "equilibrates", # The equilibrates infix macro for equilibrium interactions
+]
diff --git a/qspy/functionaltags.py b/qspy/functionaltags.py
new file mode 100644
index 0000000..e985795
--- /dev/null
+++ b/qspy/functionaltags.py
@@ -0,0 +1,580 @@
+"""
+Functional Tagging Utilities for QSP Model Components
+=====================================================
+
+This module provides standardized functional tag definitions and utilities for
+semantic annotation of model components in quantitative systems pharmacology (QSP)
+models. Tags are constructed as canonical strings (e.g., "protein::ligand") that
+combine a molecular class and a functional subclass, enabling consistent labeling,
+introspection, and validation of model entities.
+
+Classes and Enums
+-----------------
+- FunctionalTag : Dataclass for representing and parsing functional tags.
+- PROTEIN : Enum of common protein roles (e.g., ligand, receptor, kinase).
+- DRUG : Enum of drug roles (e.g., inhibitor, agonist, antibody).
+- RNA : Enum of RNA roles (e.g., messenger, micro, siRNA).
+- DNA : Enum of DNA roles (e.g., gene, promoter, enhancer).
+- METABOLITE : Enum of metabolite roles (e.g., substrate, product, cofactor).
+- LIPID : Enum of lipid roles (e.g., phospholipid, sterol).
+- ION : Enum of ion types (e.g., Ca2+, Na+, K+).
+- NANOPARTICLE : Enum of nanoparticle roles (e.g., drug delivery, imaging).
+
+Functions
+---------
+- prefixer : Utility to construct canonical tag strings from class and function labels.
+
+Examples
+--------
+>>> from qspy.functionaltags import FunctionalTag, PROTEIN
+>>> tag = FunctionalTag("protein", "ligand")
+>>> tag.value
+'protein::ligand'
+
+>>> PROTEIN.KINASE.value
+'protein::kinase'
+
+>>> FunctionalTag.parse("drug::inhibitor")
+('drug', 'inhibitor')
+"""
+
+from enum import Enum
+from dataclasses import dataclass
+
+__all__ = [
+ "PROTEIN",
+ "DRUG",
+ "RNA",
+ "DNA",
+ "METABOLITE",
+ "ION",
+ "LIPID",
+ "NANOPARTICLE",
+]
+
+TAG_SEP = "::"
+
+
+def prefixer(function: str, prefix: str, sep: str = TAG_SEP):
+ """
+ Constructs a canonical functional tag string by joining a class prefix
+ and function label using the specified separator.
+
+ This function is used to generate standardized semantic tag strings
+ (e.g., "protein::ligand") for labeling model components in a consistent,
+ machine-readable format.
+
+ Parameters
+ ----------
+ function : str
+ The functional or subclass label (e.g., "ligand").
+ prefix : str
+ The class or category label to prefix the function with (e.g., "protein").
+ sep : str, optional
+ Separator string used to join the prefix and function
+ (default is `TAG_SEP`, usually "::").
+
+ Returns
+ -------
+ str
+ Combined class/function string (e.g., "protein::ligand").
+
+ Examples
+ --------
+ >>> prefixer("regulatory", "rna")
+ 'rna::regulatory'
+
+ >>> prefixer("substrate", "metabolite", sep="__")
+ 'metabolite__substrate'
+ """
+
+ return "".join([prefix, sep, function])
+
+
+@dataclass(frozen=True)
+class FunctionalTag:
+ """
+ Represents a functional tag for labeling monomers with semantic class/function metadata.
+
+ A functional tag captures both a high-level molecular class (e.g., 'protein', 'rna') and a
+ subclass or functional role (e.g., 'ligand', 'receptor'). These tags enable semantic annotation
+ of model components to support introspection, filtering, and validation workflows.
+
+ Parameters
+ ----------
+ class_ : str
+ The molecular class label (e.g., 'protein').
+ function : str
+ The functional or subclass label (e.g., 'receptor').
+
+ Attributes
+ ----------
+ value : str
+ The canonical string representation of the tag (e.g., "protein__receptor").
+ This is derived by prefixing the function with its class using the defined separator.
+
+ Methods
+ -------
+ __eq__(other)
+ Compares functional tags by class and function. Supports comparison with
+ other FunctionalTag instances or Enum-based tag values.
+ parse(prefix_tag : str) -> Tuple[str, str]
+ Parses a canonical tag string into its (class, function) components.
+
+ Examples
+ --------
+ >>> tag = FunctionalTag("protein", "ligand")
+ >>> tag.value
+ 'protein::ligand'
+
+ >>> FunctionalTag.parse("rna::micro")
+ ('rna', 'micro')
+ """
+
+ class_: str
+ function: str
+
+ def __eq__(self, other):
+ if isinstance(other, FunctionalTag):
+ return (self.class_, self.function) == (other.class_, other.function)
+ elif isinstance(other, Enum):
+ return (self.class_, self.function) == self.parse(other.value)
+ else:
+ return False
+
+ @property
+ def value(self):
+ """
+ str: The canonical string representation of the functional tag.
+
+ Returns the tag as a string in the format "::", e.g., "protein::ligand".
+
+ Examples
+ --------
+ >>> tag = FunctionalTag("protein", "ligand")
+ >>> tag.value
+ 'protein::ligand'
+ """
+ return prefixer(self.function, self.class_)
+
+ @staticmethod
+ def parse(prefix_tag: str):
+ """
+ Parse a canonical tag string into its class and function components.
+
+ Parameters
+ ----------
+ prefix_tag : str
+ The canonical tag string (e.g., "protein::ligand").
+
+ Returns
+ -------
+ tuple of (str, str)
+ The class and function components as a tuple.
+
+ Examples
+ --------
+ >>> FunctionalTag.parse("protein::kinase")
+ ('protein', 'kinase')
+ """
+ class_, function = prefix_tag.split(TAG_SEP)
+ return class_, function
+
+
+# === Protein roles ===
+PROTEIN_PREFIX = "protein"
+
+
+class PROTEIN(Enum):
+ """
+ Functional tag definitions for protein-based monomer classes.
+
+ This enum provides standardized semantic tags for common protein roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `PROTEIN_PREFIX` with a
+ functional subclass label, producing values like "protein::ligand".
+
+ These tags are used in monomer definitions to support validation,
+ introspection, and expressive annotation of biological roles.
+
+ Members
+ -------
+ LIGAND : str
+ A signaling molecule that binds to a receptor.
+ RECEPTOR : str
+ A membrane or intracellular protein that receives ligand signals.
+ KINASE : str
+ An enzyme that phosphorylates other molecules.
+ PHOSPHATASE : str
+ An enzyme that removes phosphate groups from molecules.
+ ADAPTOR : str
+ A scaffold protein facilitating complex formation without enzymatic activity.
+ TRANSCRIPTION_FACTOR : str
+ A nuclear protein that regulates gene transcription.
+ ENZYME : str
+ A general-purpose catalytic protein.
+ ANTIBODY : str
+ An immunoglobulin capable of specific antigen binding.
+ RECEPTOR_DECOY : str
+ A non-signaling receptor mimic that competes with signaling receptors.
+
+ Examples
+ --------
+ >>> PROTEIN.LIGAND.value
+ 'protein::ligand'
+
+ >>> tag = FunctionalTag.parse(PROTEIN.KINASE.value)
+ ('protein', 'kinase')
+ """
+
+ LIGAND = prefixer("ligand", PROTEIN_PREFIX)
+ RECEPTOR = prefixer("receptor", PROTEIN_PREFIX)
+ KINASE = prefixer("kinase", PROTEIN_PREFIX)
+ PHOSPHATASE = prefixer("phosphatase", PROTEIN_PREFIX)
+ ADAPTOR = prefixer("adaptor", PROTEIN_PREFIX)
+ TRANSCRIPTION_FACTOR = prefixer("transcription_factor", PROTEIN_PREFIX)
+ ENZYME = prefixer("enzyme", PROTEIN_PREFIX)
+ ANTIBODY = prefixer("antibody", PROTEIN_PREFIX)
+ RECEPTOR_DECOY = prefixer("receptor_decoy", PROTEIN_PREFIX)
+
+
+# === Drug roles ===
+DRUG_PREFIX = "drug"
+
+
+class DRUG(Enum):
+ """
+ Functional tag definitions for drug-based monomer classes.
+
+ This enum provides standardized semantic tags for common drug roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `DRUG_PREFIX` with a
+ functional subclass label, producing values like "drug::inhibitor".
+
+ These tags are used in monomer definitions to support validation,
+ introspection, and expressive annotation of pharmacological roles.
+
+ Members
+ -------
+ SMALL_MOLECULE : str
+ A low molecular weight compound, typically orally bioavailable.
+ BIOLOGIC : str
+ A therapeutic product derived from biological sources.
+ ANTIBODY : str
+ An immunoglobulin-based therapeutic.
+ MAB : str
+ A monoclonal antibody.
+ INHIBITOR : str
+ A molecule that inhibits a biological process or target.
+ AGONIST : str
+ A molecule that activates a receptor or pathway.
+ ANTAGONIST : str
+ A molecule that blocks or dampens a biological response.
+ INVERSE_AGONIST : str
+ A molecule that induces the opposite effect of an agonist.
+ MODULATOR : str
+ A molecule that modulates the activity of a target.
+ ADC : str
+ An antibody-drug conjugate.
+ RLT : str
+ A radioligand therapy agent.
+ PROTAC : str
+ A proteolysis targeting chimera.
+ IMUNNOTHERAPY : str
+ An agent used in immunotherapy.
+ CHEMOTHERAPY : str
+ A cytotoxic agent used in chemotherapy.
+
+ Examples
+ --------
+ >>> DRUG.INHIBITOR.value
+ 'drug::inhibitor'
+
+ >>> tag = FunctionalTag.parse(DRUG.ANTIBODY.value)
+ ('drug', 'antibody')
+ """
+
+ SMALL_MOLECULE = prefixer("small_molecule", DRUG_PREFIX)
+ BIOLOGIC = prefixer("biologic", DRUG_PREFIX)
+ ANTIBODY = prefixer("antibody", DRUG_PREFIX)
+ MAB = prefixer("monoclonal_antibody", DRUG_PREFIX)
+ INHIBITOR = prefixer("inhibitor", DRUG_PREFIX)
+ AGONIST = prefixer("agonist", DRUG_PREFIX)
+ ANTAGONIST = prefixer("antagonist", DRUG_PREFIX)
+ INVERSE_AGONIST = prefixer("inverse_agonist", DRUG_PREFIX)
+ MODULATOR = prefixer("modulator", DRUG_PREFIX)
+ ADC = prefixer("antibody_drug_conjugate", DRUG_PREFIX)
+ RLT = prefixer("radioligand_therapy", DRUG_PREFIX)
+ PROTAC = prefixer("protac", DRUG_PREFIX)
+ IMUNNOTHERAPY = prefixer("immunotherapy", DRUG_PREFIX)
+ CHEMOTHERAPY = prefixer("chemotherapy", DRUG_PREFIX)
+
+
+# === RNA roles ===
+RNA_PREFIX = "rna"
+
+
+class RNA(Enum):
+ """
+ Functional tag definitions for RNA-based monomer classes.
+
+ This enum provides standardized semantic tags for common RNA roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `RNA_PREFIX` with a
+ functional subclass label, producing values like "rna::micro".
+
+ Members
+ -------
+ MESSENGER : str
+ Messenger RNA (mRNA).
+ MICRO : str
+ Micro RNA (miRNA).
+ SMALL_INTERFERING : str
+ Small interfering RNA (siRNA).
+ LONG_NONCODING : str
+ Long non-coding RNA (lncRNA).
+
+ Examples
+ --------
+ >>> RNA.MICRO.value
+ 'rna::micro'
+
+ >>> tag = FunctionalTag.parse(RNA.MESSENGER.value)
+ ('rna', 'messenger')
+ """
+
+ MESSENGER = prefixer("messenger", RNA_PREFIX)
+ MICRO = prefixer("micro", RNA_PREFIX)
+ SMALL_INTERFERING = prefixer("small_interfering", RNA_PREFIX)
+ LONG_NONCODING = prefixer("long_noncoding", RNA_PREFIX)
+
+
+# === DNA roles ===
+DNA_PREFIX = "dna"
+
+
+class DNA(Enum):
+ """
+ Functional tag definitions for DNA-based monomer classes.
+
+ This enum provides standardized semantic tags for common DNA roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `DNA_PREFIX` with a
+ functional subclass label, producing values like "dna::gene".
+
+ Members
+ -------
+ GENE : str
+ A gene region.
+ PROMOTER : str
+ A promoter region.
+ ENHANCER : str
+ An enhancer region.
+
+ Examples
+ --------
+ >>> DNA.GENE.value
+ 'dna::gene'
+
+ >>> tag = FunctionalTag.parse(DNA.PROMOTER.value)
+ ('dna', 'promoter')
+ """
+
+ GENE = prefixer("gene", DNA_PREFIX)
+ PROMOTER = prefixer("promoter", DNA_PREFIX)
+ ENHANCER = prefixer("enhancer", DNA_PREFIX)
+
+
+# === Metabolite roles ===
+METABOLITE_PREFIX = "metabolite"
+
+
+class METABOLITE(Enum):
+ """
+ Functional tag definitions for metabolite-based monomer classes.
+
+ This enum provides standardized semantic tags for common metabolite roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `METABOLITE_PREFIX` with a
+ functional subclass label, producing values like "metabolite::substrate".
+
+ Members
+ -------
+ SUBSTRATE : str
+ A substrate molecule in a metabolic reaction.
+ PRODUCT : str
+ A product molecule in a metabolic reaction.
+ COFACTOR : str
+ A cofactor required for enzyme activity.
+
+ Examples
+ --------
+ >>> METABOLITE.SUBSTRATE.value
+ 'metabolite::substrate'
+
+ >>> tag = FunctionalTag.parse(METABOLITE.PRODUCT.value)
+ ('metabolite', 'product')
+ """
+
+ SUBSTRATE = prefixer("substrate", METABOLITE_PREFIX)
+ PRODUCT = prefixer("product", METABOLITE_PREFIX)
+ COFACTOR = prefixer("cofactor", METABOLITE_PREFIX)
+
+
+# === Lipid roles ===
+LIPID_PREFIX = "lipid"
+
+
+class LIPID(Enum):
+ """
+ Functional tag definitions for lipid-based monomer classes.
+
+ This enum provides standardized semantic tags for common lipid roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `LIPID_PREFIX` with a
+ functional subclass label, producing values like "lipid::phospholipid".
+
+ Members
+ -------
+ PHOSPHOLIPID : str
+ A phospholipid molecule.
+ GLYCOLIPID : str
+ A glycolipid molecule.
+ STEROL : str
+ A sterol molecule.
+ EICOSANOID : str
+ An eicosanoid molecule.
+
+ Examples
+ --------
+ >>> LIPID.PHOSPHOLIPID.value
+ 'lipid::phospholipid'
+
+ >>> tag = FunctionalTag.parse(LIPID.STEROL.value)
+ ('lipid', 'sterol')
+ """
+
+ PHOSPHOLIPID = prefixer("phospholipid", LIPID_PREFIX)
+ GLYCOLIPID = prefixer("glycolipid", LIPID_PREFIX)
+ STEROL = prefixer("sterol", LIPID_PREFIX)
+ EICOSANOID = prefixer("eicosanoid", LIPID_PREFIX)
+
+
+# === Ion types ===
+ION_PREFIX = "ion"
+
+
+class ION(Enum):
+ """
+ Functional tag definitions for ion-based monomer classes.
+
+ This enum provides standardized semantic tags for common ion types
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `ION_PREFIX` with a
+ functional subclass label, producing values like "ion::ca2+".
+
+ Members
+ -------
+ CALCIUM : str
+ Calcium ion (Ca2+).
+ POTASSIUM : str
+ Potassium ion (K+).
+ SODIUM : str
+ Sodium ion (Na+).
+ CHLORIDE : str
+ Chloride ion (Cl-).
+ MAGNESIUM : str
+ Magnesium ion (Mg2+).
+
+ Examples
+ --------
+ >>> ION.CALCIUM.value
+ 'ion::ca2+'
+
+ >>> tag = FunctionalTag.parse(ION.SODIUM.value)
+ ('ion', 'na+')
+ """
+
+ CALCIUM = prefixer("ca2+", ION_PREFIX)
+ POTASSIUM = prefixer("k+", ION_PREFIX)
+ SODIUM = prefixer("na+", ION_PREFIX)
+ CHLORIDE = prefixer("cl-", ION_PREFIX)
+ MAGNESIUM = prefixer("mg2+", ION_PREFIX)
+
+
+# === Nanoparticle roles ===
+NANOPARTICLE_PREFIX = "nanoparticle"
+
+
+class NANOPARTICLE(Enum):
+ """
+ Functional tag definitions for nanoparticle-based monomer classes.
+
+ This enum provides standardized semantic tags for common nanoparticle roles
+ in quantitative systems pharmacology models. Each member is constructed
+ using the `prefixer` utility to combine the `NANOPARTICLE_PREFIX` with a
+ functional subclass label, producing values like "nanoparticle::imaging".
+
+ Members
+ -------
+ DRUG_DELIVERY : str
+ Nanoparticle for drug delivery.
+ THERMAL : str
+ Photothermal nanoparticle.
+ IMAGING : str
+ Nanoparticle for imaging.
+ SENSING : str
+ Nanoparticle for sensing.
+ THERANOSTIC : str
+ Theranostic nanoparticle.
+
+ Examples
+ --------
+ >>> NANOPARTICLE.DRUG_DELIVERY.value
+ 'nanoparticle::drug_delivery'
+
+ >>> tag = FunctionalTag.parse(NANOPARTICLE.IMAGING.value)
+ ('nanoparticle', 'imaging')
+ """
+
+ DRUG_DELIVERY = prefixer("drug_delivery", NANOPARTICLE_PREFIX)
+ THERMAL = prefixer("photothermal", NANOPARTICLE_PREFIX)
+ IMAGING = prefixer("imaging", NANOPARTICLE_PREFIX)
+ SENSING = prefixer("sensing", NANOPARTICLE_PREFIX)
+ THERANOSTIC = prefixer("theranostic", NANOPARTICLE_PREFIX)
+
+
+# # === Rate constant orders ===
+# RATE_PREFIX = "rate_constant"
+
+
+# class RATE(Enum):
+# """
+# Functional tag definitions for kinetic rate types.
+
+# This enum provides standardized semantic tags for common kinetic rate orders
+# in quantitative systems pharmacology models. Each member is constructed
+# using the `prefixer` utility to combine the `RATE_PREFIX` with a
+# rate order label, producing values like "rate_constant::first-order".
+
+# Members
+# -------
+# ZERO : str
+# Zero-order rate constant (rate independent of concentration).
+# FIRST : str
+# First-order rate constant (rate proportional to one reactant).
+# SECOND : str
+# Second-order rate constant (rate proportional to two reactants).
+
+# Examples
+# --------
+# >>> RATE.FIRST.value
+# 'rate_constant::first-order'
+
+# >>> tag = FunctionalTag.parse(RATE.SECOND.value)
+# ('rate_constant', 'second-order')
+# """
+
+# ZERO = prefixer("zero-order", RATE_PREFIX)
+# FIRST = prefixer("first-order", RATE_PREFIX)
+# SECOND = prefixer("second-order", RATE_PREFIX)
diff --git a/qspy/macros/__init__.py b/qspy/macros/__init__.py
new file mode 100644
index 0000000..94f8c43
--- /dev/null
+++ b/qspy/macros/__init__.py
@@ -0,0 +1,9 @@
+from pysb import macros as core # Import core PySB macros
+from pysb.pkpd import macros as pkpd # Import PySB PK/PD macros
+from pysb.units import add_macro_units
+
+# Add units to both core and PK/PD macros
+add_macro_units(core)
+add_macro_units(pkpd)
+
+__all__ = ["core", "pkpd"]
diff --git a/qspy/utils/__init__.py b/qspy/utils/__init__.py
new file mode 100644
index 0000000..0795d01
--- /dev/null
+++ b/qspy/utils/__init__.py
@@ -0,0 +1,7 @@
+"""
+QSPy Utilities Subpackage
+=========================
+
+This subpackage contains utility modules for QSPy, including logging, helper functions,
+and other shared tools used throughout the package.
+"""
\ No newline at end of file
diff --git a/qspy/utils/diagrams.py b/qspy/utils/diagrams.py
new file mode 100644
index 0000000..f85d575
--- /dev/null
+++ b/qspy/utils/diagrams.py
@@ -0,0 +1,269 @@
+"""
+QSPy Model Diagram Generation Utilities
+=======================================
+
+This module provides utilities for generating flowchart-style diagrams of QSPy/PySB models.
+It leverages mergram and pyvipr to visualize model structure, including compartments,
+species, and reactions, and can export diagrams as Mermaid, Markdown, or HTML blocks.
+
+Classes
+-------
+ModelMermaidDiagrammer : Generates and exports flowchart diagrams for a given model.
+
+Examples
+--------
+>>> from qspy.diagrams import ModelDiagram
+>>> diagram = ModelDiagram(model)
+>>> print(diagram.markdown_block)
+>>> diagram.write_mermaid_file("model_flowchart.mmd")
+"""
+
+from pathlib import Path
+
+from mergram.flowchart import Flowchart, Node, Link, Subgraph, Style
+from pyvipr.pysb_viz.static_viz import PysbStaticViz
+from pysb.core import SelfExporter
+import seaborn as sns
+
+from qspy.config import METADATA_DIR
+
+
+class ModelMermaidDiagrammer:
+ """
+ Generates a Mermaid flowchart diagram of a QSPy/PySB model.
+
+ This class builds a flowchart representation of the model, including compartments,
+ species, and reactions, and provides export options for Mermaid, Markdown, and HTML.
+
+ Parameters
+ ----------
+ model : pysb.Model, optional
+ The model to visualize. If None, uses the current SelfExporter.default_model.
+ output_dir : str or Path, optional
+ Directory to write diagram files (default: METADATA_DIR).
+
+ Attributes
+ ----------
+ model : pysb.Model
+ The model being visualized.
+ flowchart : Flowchart
+ The generated flowchart object.
+ static_viz : PysbStaticViz
+ Static visualization helper for the model.
+ has_compartments : bool
+ Whether the model contains compartments.
+ output_dir : Path
+ Directory for output files.
+
+ Methods
+ -------
+ write_mermaid_file(file_path)
+ Write the flowchart to a Mermaid file.
+ markdown_block
+ Return the flowchart as a Markdown block.
+ html_block
+ Return the flowchart as an HTML block.
+ """
+
+ def __init__(self, model=None, output_dir=METADATA_DIR):
+ """
+ Initialize the ModelMermaidDiagrammer.
+
+ Parameters
+ ----------
+ model : pysb.Model, optional
+ The model to visualize. If None, uses the current SelfExporter.default_model.
+ output_dir : str or Path, optional
+ Directory to write diagram files (default: METADATA_DIR).
+ """
+ self.model = model
+ if model is None:
+ self.model = SelfExporter.default_model
+ self.flowchart = Flowchart(self.model.name)
+ self.static_viz = PysbStaticViz(self.model)
+ self.has_compartments = len(self.model.compartments) > 0
+ self._build_flowchart()
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+ chart_file = self.output_dir / f"{self.model.name}_flowchart.mmd"
+ self.write_mermaid_file(chart_file.as_posix())
+ setattr(self.model, "mermaid_diagram", self)
+ return
+
+ @staticmethod
+ def _sanitize_label(label):
+ """
+ Sanitize a label by removing compartment information.
+
+ Parameters
+ ----------
+ label : str
+ The label to sanitize.
+
+ Returns
+ -------
+ str
+ The sanitized label without compartment information.
+ """
+ #print(label)
+ if "** " in label:
+ if "-" in label:
+ # Remove compartment from labels with bound monomers
+ # e.g., "molec_a() ** CENTRAL % molec_b() ** TUMOR"
+ # becomes "molec_a() % molec_b()"
+ parts = label.split("-")
+ return parts[0].split("** ")[0] + ":" + parts[1].split("** ")[0]
+ else:
+ # Remove compartment info
+ # e.g., "molec_a() ** CENTRAL" -> "molec_a()"
+ return label.split("** ")[0]
+ return label
+
+ def _build_flowchart(self):
+ """
+ Build the flowchart representation of the model.
+
+ Parses the model's compartments, species, and reactions, and adds them as nodes,
+ subgraphs, and links to the flowchart.
+ """
+ if self.has_compartments:
+ nx_graph = self.static_viz.compartments_data_graph()
+ else:
+ nx_graph = self.static_viz.species_graph()
+ # Add nodes and edges for unidirection `>> None` reactions.
+ # This is to handle cases where a reaction has no products.
+ for reaction in self.model.reactions_bidirectional:
+ reactants = set(reaction["reactants"])
+ products = set(reaction["products"])
+ rule = self.model.rules[reaction["rule"][0]]
+ k_f = rule.rate_forward.name
+ if len(products) < 1:
+ for s in reactants:
+ s_id = f"s{s}"
+ node_comp = nx_graph.nodes[s_id]["parent"]
+ nx_graph.add_node(
+ "none",
+ NodeType="none",
+ parent=node_comp,
+ label='"fa:fa-circle-xmark"',
+ background_color="#fff",
+ )
+ nx_graph.add_edge(s_id, "none", k_f=k_f, k_r=" ")
+
+ # Parse the networkx graph nodes into flowchart nodes.
+ # Create subgraphs for compartments if they exist.
+ # Otherwise, create nodes for species.
+ flow_nodes = dict()
+ for node_id, node_attr in nx_graph.nodes.data():
+ if node_attr["NodeType"] == "compartment":
+ if node_id not in self.flowchart.subgraphs:
+ self.flowchart += Subgraph(node_id)
+ elif node_attr["NodeType"] == "species":
+ flow_node = Node(
+ node_id,
+ label=self._sanitize_label(node_attr.get("label", node_id)),
+ fill=node_attr.get("background_color", "#fff"),
+ )
+ compartment = node_attr["parent"]
+ if compartment not in self.flowchart.subgraphs:
+ self.flowchart += Subgraph(compartment)
+ self.flowchart.subgraphs[compartment] += flow_node
+ flow_nodes[node_id] = flow_node
+ elif node_attr["NodeType"] == "none":
+ flow_node = Node(
+ node_id,
+ label=self._sanitize_label(node_attr.get("label", node_id)),
+ fill=node_attr.get("background_color", "#fff"),
+ shape="fr-circ",
+ )
+ self.flowchart += flow_node
+ flow_nodes[node_id] = flow_node
+ # Go through the reactions and get the names of rate constants
+ # and add them to the networkx edge attributes.
+ for reaction in self.model.reactions_bidirectional:
+ reactants = set(reaction["reactants"])
+ products = set(reaction["products"])
+ rule = self.model.rules[reaction["rule"][0]]
+ k_f = rule.rate_forward.name
+ if rule.rate_reverse is None:
+ k_r = " "
+ else:
+ k_r = rule.rate_reverse.name
+
+ for s in reactants:
+ s_id = f"s{s}"
+ for p in products:
+ p_id = f"s{p}"
+ if nx_graph.has_edge(s_id, p_id):
+ nx_graph[s_id][p_id]["k_f"] = k_f
+ nx_graph[s_id][p_id]["k_r"] = k_r
+
+ for source, target, edge_attr in nx_graph.edges.data():
+ if source in flow_nodes and target in flow_nodes:
+ self.flowchart += Link(
+ flow_nodes[source],
+ flow_nodes[target],
+ text=edge_attr.get("k_f", " "),
+ )
+ if edge_attr.get("source_arrow_shape") == "triangle":
+ # Reaction is reversible
+ self.flowchart += Link(
+ flow_nodes[target],
+ flow_nodes[source],
+ text=edge_attr.get("k_r", " "),
+ )
+ # Add colors to subgraphs for compartments
+ comp_colors = sns.color_palette(
+ "Set2", n_colors=len(self.flowchart.subgraphs)
+ ).as_hex()
+ for subgraph in self.flowchart.subgraphs.values():
+ s_id = subgraph.title
+ style = Style(
+ s_id,
+ rx="10px",
+ fill=comp_colors.pop(0),
+ color="#000",
+ )
+ self.flowchart += style
+
+ return
+
+ @property
+ def markdown_block(self):
+ """
+ Return the flowchart as a Markdown block.
+
+ Returns
+ -------
+ str
+ Markdown representation of the flowchart.
+ """
+ return self.flowchart.to_markdown()
+
+ @property
+ def html_block(self):
+ """
+ Return the flowchart as an HTML block.
+
+ Returns
+ -------
+ str
+ HTML representation of the flowchart.
+ """
+ return self.flowchart.to_html()
+
+ def write_mermaid_file(self, file_path):
+ """
+ Write the flowchart to a Mermaid file.
+
+ Parameters
+ ----------
+ file_path : str or Path
+ Path to the output Mermaid (.mmd) file.
+
+ Returns
+ -------
+ None
+ """
+ self.flowchart.write(file_path)
+ return
diff --git a/qspy/utils/logging.py b/qspy/utils/logging.py
new file mode 100644
index 0000000..a21ed27
--- /dev/null
+++ b/qspy/utils/logging.py
@@ -0,0 +1,264 @@
+"""
+QSPy Logging Utilities
+======================
+
+This module provides logging utilities for QSPy, including logger setup, event
+decorators, metadata redaction, and context entry/exit logging. It ensures
+consistent, structured, and optionally redacted logging for QSPy workflows.
+
+Functions
+---------
+setup_qspy_logger : Set up the QSPy logger with rotating file handler.
+ensure_qspy_logging : Ensure the QSPy logger is initialized.
+log_event : Decorator for logging function entry, exit, arguments, and results.
+redact_sensitive : Recursively redact sensitive fields in a dictionary.
+log_model_metadata : Log model metadata in a structured, optionally redacted format.
+log_context_entry_exit : Decorator for logging context manager entry/exit.
+
+Examples
+--------
+>>> @log_event(log_args=True, log_result=True)
+... def foo(x): return x + 1
+>>> foo(2)
+"""
+
+import functools
+import logging
+import pprint
+import time
+from logging.handlers import RotatingFileHandler
+from pathlib import Path
+
+from qspy.config import LOG_PATH, LOGGER_NAME
+
+
+def setup_qspy_logger(
+ log_path=LOG_PATH, max_bytes=1_000_000, backup_count=5, level=logging.INFO
+):
+ """
+ Set up the QSPy logger with a rotating file handler.
+
+ Parameters
+ ----------
+ log_path : str or Path, optional
+ Path to the log file (default: LOG_PATH).
+ max_bytes : int, optional
+ Maximum size of a log file before rotation (default: 1,000,000).
+ backup_count : int, optional
+ Number of backup log files to keep (default: 5).
+ level : int, optional
+ Logging level (default: logging.INFO).
+
+ Returns
+ -------
+ logging.Logger
+ The configured QSPy logger.
+ """
+ log_file = Path(log_path)
+ log_file.parent.mkdir(parents=True, exist_ok=True)
+
+ handler = RotatingFileHandler(
+ filename=log_file,
+ maxBytes=max_bytes,
+ backupCount=backup_count,
+ encoding="utf-8",
+ )
+
+ formatter = logging.Formatter(
+ fmt="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
+ )
+ handler.setFormatter(formatter)
+
+ logger = logging.getLogger(LOGGER_NAME)
+ logger.setLevel(level)
+
+ if not logger.hasHandlers(): # Prevent duplicate handlers on reload
+ logger.addHandler(handler)
+
+ logger.info("QSPy logging initialized.")
+ return logger
+
+
+_LOGGER_INITIALIZED = False
+
+
+def ensure_qspy_logging():
+ """
+ Ensure the QSPy logger is initialized.
+
+ Returns
+ -------
+ None
+ """
+ global _LOGGER_INITIALIZED
+ if not _LOGGER_INITIALIZED:
+ setup_qspy_logger()
+ _LOGGER_INITIALIZED = True
+
+
+def log_event(
+ logger_name=LOGGER_NAME, log_args=False, log_result=False, static_method=False
+):
+ """
+ Decorator for logging function entry, exit, arguments, and results.
+
+ Parameters
+ ----------
+ logger_name : str, optional
+ Name of the logger to use (default: LOGGER_NAME).
+ log_args : bool, optional
+ If True, log function arguments (default: False).
+ log_result : bool, optional
+ If True, log function result (default: False).
+ static_method : bool, optional
+ If True, skip the first argument (for static methods).
+
+ Returns
+ -------
+ function
+ Decorated function with logging.
+ """
+ ensure_qspy_logging()
+ logger = logging.getLogger(logger_name)
+
+ def decorator(func):
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ if static_method:
+ args = args[1:]
+ fname = func.__qualname__
+ logger.info(f">>> Entering `{fname}`")
+ if log_args:
+ logger.info(f" Args: {args}, Kwargs: {kwargs}")
+ start = time.time()
+ result = func(*args, **kwargs)
+ duration = time.time() - start
+ logger.info(f"<<< Exiting `{fname}` ({duration:.3f}s)")
+ if log_result:
+ logger.info(f" Result: {result}")
+ return result
+
+ return wrapper
+
+ return decorator
+
+
+REDACT_KEYS = {"current_user", "author", "hostname", "ip", "email"}
+
+
+def redact_sensitive(data):
+ """
+ Recursively redact sensitive fields in a dictionary or list.
+
+ Parameters
+ ----------
+ data : dict or list or object
+ The data structure to redact.
+
+ Returns
+ -------
+ object
+ The redacted data structure.
+ """
+ if isinstance(data, dict):
+ return {
+ k: "[REDACTED]" if k in REDACT_KEYS else redact_sensitive(v)
+ for k, v in data.items()
+ }
+ elif isinstance(data, list):
+ return [redact_sensitive(i) for i in data]
+ return data
+
+
+def log_model_metadata(
+ metadata, logger_name=LOGGER_NAME, level=logging.INFO, redact=True
+):
+ """
+ Log QSPy model metadata in structured format, with optional redaction.
+
+ Parameters
+ ----------
+ metadata : dict
+ The metadata dictionary to log.
+ logger_name : str, optional
+ Name of the logger.
+ level : int, optional
+ Logging level.
+ redact : bool, optional
+ If True, redact sensitive fields (default: True).
+
+ Returns
+ -------
+ None
+ """
+ logger = logging.getLogger(logger_name)
+ if not metadata:
+ logger.warning("No metadata provided to log.")
+ return
+
+ header = "QSPy metadata snapshot"
+ if redact:
+ metadata = redact_sensitive(metadata)
+ header += " (sensitive fields redacted)"
+ logger.log(level, header + ":")
+
+ for line in pprint.pformat(metadata, indent=2).splitlines():
+ logger.log(level, f" {line}")
+
+
+def log_context_entry_exit(logger_name=LOGGER_NAME, log_duration=True, track_attr=None):
+ """
+ Decorator for logging context manager entry and exit, with optional duration and tracking.
+
+ Parameters
+ ----------
+ logger_name : str, optional
+ Name of the logger to use (default: LOGGER_NAME).
+ log_duration : bool, optional
+ If True, log the duration of the context (default: True).
+ track_attr : str or None, optional
+ If provided, track additions to this model attribute (e.g., 'rules').
+
+ Returns
+ -------
+ function
+ Decorated context manager method.
+ """
+
+ def decorator(method):
+ @functools.wraps(method)
+ def wrapper(self, *args, **kwargs):
+ logger = logging.getLogger(logger_name)
+ context_name = getattr(self, "name", self.__class__.__name__)
+ is_enter = method.__name__ == "__enter__"
+ is_exit = method.__name__ == "__exit__"
+
+ if is_enter:
+ self._qspy_context_start = time.time()
+ self._qspy_pre_ids = set()
+ if track_attr:
+ tracked = getattr(self.model, track_attr, [])
+ self._qspy_pre_ids = set(id(x) for x in tracked)
+ logger.info(f">>> Entering context: `{context_name}`")
+
+ result = method(self, *args, **kwargs)
+
+ if is_exit:
+ duration = ""
+ if log_duration and hasattr(self, "_qspy_context_start"):
+ elapsed = time.time() - self._qspy_context_start
+ duration = f" (duration: {elapsed:.3f}s)"
+ logger.info(f"<<< Exiting context: `{context_name}`{duration}")
+
+ if track_attr:
+ tracked = getattr(self.model, track_attr, [])
+ added = [x for x in tracked if id(x) not in self._qspy_pre_ids]
+ logger.info(f" ↳ Added {len(added)} new `{track_attr}`:")
+ for obj in added:
+ logger.info(f" - {getattr(obj, 'name', repr(obj))}")
+
+ return result
+
+ return wrapper
+
+ return decorator
diff --git a/qspy/validation/__init__.py b/qspy/validation/__init__.py
new file mode 100644
index 0000000..7686d20
--- /dev/null
+++ b/qspy/validation/__init__.py
@@ -0,0 +1,19 @@
+"""
+QSPy Validation Subpackage
+==========================
+
+This subpackage provides tools for validating QSPy models and tracking model metadata.
+
+Modules
+-------
+- metadata : Model metadata tracking and export utilities.
+- modelchecker : Automated model validation and consistency checks.
+
+Classes
+-------
+- ModelMetadataTracker
+- ModelChecker
+"""
+
+from qspy.validation.metadata import ModelMetadataTracker
+from qspy.validation.modelchecker import ModelChecker
diff --git a/qspy/validation/metadata.py b/qspy/validation/metadata.py
new file mode 100644
index 0000000..69dac3d
--- /dev/null
+++ b/qspy/validation/metadata.py
@@ -0,0 +1,285 @@
+"""
+QSPy Model Metadata Tracking and Export
+=======================================
+
+This module provides utilities for capturing, tracking, and exporting model metadata
+in QSPy workflows. It includes environment capture, model hashing, and TOML export
+for reproducibility and provenance tracking.
+
+Classes
+-------
+QSPyBench : MicroBench-based class for capturing Python and host environment info.
+ModelMetadataTracker : Tracks model metadata, environment, and provides export/load utilities.
+
+Examples
+--------
+>>> tracker = ModelMetadataTracker(version="1.0", author="Alice", export_toml=True)
+>>> tracker.metadata["model_name"]
+'MyModel'
+>>> ModelMetadataTracker.load_metadata_toml("MyModel__Alice__abcd1234__2024-07-01.toml")
+"""
+
+import getpass
+import hashlib
+import io
+import json
+import logging
+import os
+import platform
+from datetime import datetime
+from pathlib import Path
+
+import numpy
+import pysb
+import scipy
+import sympy
+import pysb.pkpd
+import pysb.units
+import mergram
+import toml
+from microbench import MicroBench, MBHostInfo, MBPythonVersion
+from pysb.core import SelfExporter
+
+import qspy
+from qspy.config import METADATA_DIR, LOGGER_NAME
+from qspy.utils.logging import ensure_qspy_logging
+
+
+class QSPyBench(MicroBench, MBPythonVersion, MBHostInfo):
+ """
+ MicroBench-based class for capturing Python and host environment information.
+
+ Captures versions of key scientific libraries and host metadata for reproducibility.
+
+ Attributes
+ ----------
+ capture_versions : tuple
+ Tuple of modules to capture version info for (qspy, numpy, scipy, sympy, pysb, pysb.pkpd, pysb.units, mergram).
+ """
+ capture_versions = (qspy, numpy, scipy, sympy, pysb, pysb.pkpd, pysb.units, mergram)
+
+
+class ModelMetadataTracker:
+ """
+ Tracks and exports QSPy model metadata, including environment and hash.
+
+ On initialization, captures model version, author, user, timestamp, hash, and
+ environment metadata. Optionally exports metadata to TOML.
+
+ Parameters
+ ----------
+ version : str, optional
+ Model version string (default "0.1.0").
+ author : str, optional
+ Author name (default: current user).
+ export_toml : bool, optional
+ If True, export metadata to TOML on creation (default: False).
+ capture_conda_env : bool, optional
+ If True, capture the active conda environment name (default: False).
+
+ Attributes
+ ----------
+ model : pysb.Model
+ The active PySB model instance.
+ version : str
+ Model version.
+ author : str
+ Author name.
+ current_user : str
+ Username of the current user.
+ timestamp : str
+ ISO timestamp of metadata creation.
+ hash : str
+ SHA256 hash of model rules and parameters.
+ env_metadata : dict
+ Captured environment metadata.
+ metadata : dict
+ Full metadata dictionary for export.
+
+ Methods
+ -------
+ compute_model_hash()
+ Compute a hash from model rules and parameters.
+ capture_environment()
+ Capture execution environment metadata.
+ export_metadata_toml(path=None, use_metadata_dir=True)
+ Export metadata to a TOML file.
+ load_metadata_toml(path)
+ Load metadata from a TOML file.
+
+ Examples
+ --------
+ >>> tracker = ModelMetadataTracker(version="1.0", author="Alice", export_toml=True)
+ >>> tracker.metadata["model_name"]
+ 'MyModel'
+ """
+
+ def __init__(
+ self, version="0.1.0", author=None, export_toml=False, capture_conda_env=False
+ ):
+ """
+ Initialize the ModelMetadataTracker.
+
+ Parameters
+ ----------
+ version : str, optional
+ Model version string (default "0.1.0").
+ author : str, optional
+ Author name (default: current user).
+ export_toml : bool, optional
+ If True, export metadata to TOML on creation (default: False).
+ capture_conda_env : bool, optional
+ If True, capture the active conda environment name (default: False).
+
+ Raises
+ ------
+ RuntimeError
+ If no model is found in the current SelfExporter context.
+ """
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ try:
+ self.model = SelfExporter.default_model
+ if not self.model:
+ logger.error("No model found in the current SelfExporter context")
+ raise RuntimeError("No model found in the current SelfExporter context")
+ self.version = version
+ self.author = author or "unknown"
+ self.current_user = getpass.getuser()
+ self.timestamp = datetime.now().isoformat()
+ self.hash = self.compute_model_hash()
+ self.env_metadata = self.capture_environment()
+
+ if capture_conda_env:
+ conda_env = os.environ.get("CONDA_DEFAULT_ENV", None)
+ if conda_env:
+ self.env_metadata["conda_env"] = conda_env
+
+ self.metadata = {
+ "version": self.version,
+ "author": self.author,
+ "current_user": self.current_user,
+ "created_at": self.timestamp,
+ "hash": self.hash,
+ "model_name": self.model.name or "unnamed_model",
+ "env": self.env_metadata,
+ }
+
+ # Attach the metadata tracker to the model
+ setattr(self.model, "qspy_metadata_tracker", self)
+
+ if export_toml:
+ self.export_metadata_toml()
+ except Exception as e:
+ logger.error(f"[QSPy][ERROR] Exception in ModelMetadataTracker.__init__: {e}")
+ raise
+
+ def compute_model_hash(self):
+ """
+ Create a hash from model definition (rules + parameters).
+
+ Returns
+ -------
+ str
+ SHA256 hash of the model's rules and parameters.
+ """
+ try:
+ s = repr(self.model.rules) + repr(self.model.parameters)
+ return hashlib.sha256(s.encode()).hexdigest()
+ except Exception as e:
+ logger = logging.getLogger(LOGGER_NAME)
+ logger.error(f"[QSPy][ERROR] Exception in compute_model_hash: {e}")
+ raise
+
+ def capture_environment(self):
+ """
+ Capture execution environment via microbench.
+
+ Returns
+ -------
+ dict
+ Dictionary of captured environment metadata.
+ """
+ try:
+ bench = QSPyBench()
+
+ @bench
+ def noop():
+ pass
+
+ noop()
+ bench.outfile.seek(0)
+ metadata = bench.outfile.read()
+ if metadata == "":
+ return {"microbench": "No metadata captured."}
+ else:
+ return json.loads(metadata)
+ except Exception as e:
+ logger = logging.getLogger(LOGGER_NAME)
+ logger.error(f"[QSPy][ERROR] Exception in capture_environment: {e}")
+ return {"microbench": f"Error capturing metadata: {e}"}
+
+ def export_metadata_toml(self, path=None, use_metadata_dir=True):
+ """
+ Export metadata to a TOML file with autogenerated filename if none is provided.
+
+ Parameters
+ ----------
+ path : str or Path, optional
+ Output path for the TOML file. If None, an autogenerated filename is used.
+ use_metadata_dir : bool, optional
+ If True, use the configured METADATA_DIR for output (default: True).
+
+ Returns
+ -------
+ None
+ """
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ try:
+ metadata_dir = Path(METADATA_DIR) if use_metadata_dir else Path(".")
+ metadata_dir.mkdir(parents=True, exist_ok=True)
+
+ if path is None:
+ safe_author = self.author.replace(" ", "_")
+ safe_name = (self.model.name or "model").replace(" ", "_")
+ short_hash = self.hash[:8]
+ safe_time = self.timestamp.replace(":", "-")
+ filename = f"{safe_name}__{safe_author}__{short_hash}__{safe_time}.toml"
+ path = metadata_dir / filename
+
+ with open(path, "w") as f:
+ toml.dump(self.metadata, f)
+ logger.info(f"Exported model metadata to TOML: {path}")
+ except Exception as e:
+ logger.error(f"[QSPy][ERROR] Exception in export_metadata_toml: {e}")
+ raise
+
+ @staticmethod
+ def load_metadata_toml(path):
+ """
+ Load model metadata from a TOML file.
+
+ Parameters
+ ----------
+ path : str or Path
+ Path to the TOML metadata file.
+
+ Returns
+ -------
+ dict
+ Loaded metadata dictionary.
+
+ Raises
+ ------
+ Exception
+ If loading fails.
+ """
+ ensure_qspy_logging()
+ logger = logging.getLogger(LOGGER_NAME)
+ try:
+ with open(path, "r") as f:
+ return toml.load(f)
+ except Exception as e:
+ logger.error(f"[QSPy][ERROR] Exception in load_metadata_toml: {e}")
+ raise
diff --git a/qspy/validation/modelchecker.py b/qspy/validation/modelchecker.py
new file mode 100644
index 0000000..3bf1672
--- /dev/null
+++ b/qspy/validation/modelchecker.py
@@ -0,0 +1,342 @@
+"""
+QSPy ModelChecker Utilities
+===========================
+
+This module provides the ModelChecker class for validating PySB/QSPy models.
+It checks for unused or zero-valued parameters, unused monomers, missing initial
+conditions, dangling bonds, unit consistency, and other common modeling issues.
+Warnings are logged and also issued as Python warnings for user visibility.
+
+Classes
+-------
+ModelChecker : Performs a suite of checks on a PySB/QSPy model for common issues.
+
+Examples
+--------
+>>> checker = ModelChecker(model)
+>>> checker.check()
+"""
+
+import logging
+import warnings
+
+import numpy as np
+
+from pysb.core import SelfExporter, MonomerPattern
+from pysb.pattern import (
+ check_dangling_bonds,
+ monomers_from_pattern,
+ SpeciesPatternMatcher,
+ RulePatternMatcher,
+ ReactionPatternMatcher,
+)
+from pysb.bng import generate_equations
+from pysb.units.core import check as units_check
+
+from qspy.core import Monomer, Parameter
+from qspy.utils.logging import ensure_qspy_logging, log_event
+from qspy.config import LOGGER_NAME
+
+warnings.simplefilter("always", UserWarning) # Always show UserWarnings
+
+
+class ModelChecker:
+ """
+ Performs a suite of checks on a PySB/QSPy model for common modeling issues.
+
+ Checks include:
+ - Unused monomers
+ - Unused parameters
+ - Zero-valued parameters
+ - Missing initial conditions
+ - Dangling/reused bonds
+ - Unit consistency
+ - (Optional) unbound sites, overdefined rules, unreferenced expressions
+
+ Parameters
+ ----------
+ model : pysb.Model, optional
+ The model to check. If None, uses the current SelfExporter.default_model.
+ logger_name : str, optional
+ Name of the logger to use (default: LOGGER_NAME).
+
+ Attributes
+ ----------
+ model : pysb.Model
+ The model being checked.
+ logger : logging.Logger
+ Logger for outputting warnings and info.
+ """
+
+ def __init__(self, model=None, logger_name=LOGGER_NAME):
+ """
+ Initialize the ModelChecker.
+
+ Parameters
+ ----------
+ model : pysb.Model, optional
+ The model to check. If None, uses the current SelfExporter.default_model.
+ logger_name : str, optional
+ Name of the logger to use.
+ """
+ self.model = model
+ if model is None:
+ self.model = SelfExporter.default_model
+ ensure_qspy_logging()
+ self.logger = logging.getLogger(logger_name)
+ self.check()
+
+ @log_event()
+ def check(self):
+ """
+ Run all model checks.
+
+ Returns
+ -------
+ None
+ """
+ self.logger.info("🔍 Running ModelChecker...")
+ self.check_unused_monomers()
+ self.check_unused_parameters()
+ self.check_zero_valued_parameters()
+ self.check_missing_initial_conditions()
+ # self.check_unconnected_species()
+ # self.check_unbound_sites()
+ # self.check_overdefined_rules()
+ # self.check_unreferenced_expressions()
+ # units_check(self.model)
+ self.check_dangling_reused_bonds()
+ self.check_units()
+ self.check_equations_generation()
+ self.logger.info("✅ ModelChecker checks completed.")
+
+ def check_unused_monomers(self):
+ """
+ Check for monomers that are not used in any rules.
+
+ Logs and warns about unused monomers.
+
+ Returns
+ -------
+ None
+ """
+ used = set()
+ for rule in self.model.rules:
+ used.update(
+ m.name
+ for m in monomers_from_pattern(rule.rule_expression.reactant_pattern)
+ )
+ # monomers_from_pattern doesn't handle None, so we need to
+ # check here or it will cause a problem with the set.union method.
+ if rule.is_reversible:
+ used.update(
+ m.name
+ for m in monomers_from_pattern(rule.rule_expression.product_pattern)
+ )
+
+ unused = [m.name for m in self.model.monomers if m.name not in used]
+ if len(unused) > 0:
+ msg = f"Unused Monomers (not included in any Rules): {[m for m in unused]}"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
+ print(f"⚠️ {msg}") # Print to console for visibility
+
+ def check_unused_parameters(self):
+ """
+ Check for parameters that are not used in rules, initials, or expressions.
+
+ Logs and warns about unused parameters.
+
+ Returns
+ -------
+ None
+ """
+ used = set()
+ for rule in self.model.rules:
+ if isinstance(rule.rate_forward, Parameter):
+ used.add(rule.rate_forward.name)
+ if rule.is_reversible:
+ if isinstance(rule.rate_reverse, Parameter):
+ used.add(rule.rate_reverse.name)
+ for ic in self.model.initials:
+ if isinstance(ic.value, Parameter):
+ used.add(ic.value.name)
+ for expr in self.model.expressions:
+ used.update(p.name for p in expr.expr.atoms(Parameter))
+ for compartment in self.model.compartments:
+ if isinstance(compartment.size, Parameter):
+ used.add(compartment.size.name)
+
+ unused = [p.name for p in self.model.parameters if p.name not in used]
+ if unused:
+ msg = f"Unused Parameters: {[p for p in unused]}"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
+ print(f"⚠️ {msg}") # Print to console for visibility
+
+ def check_zero_valued_parameters(self):
+ """
+ Check for parameters with a value of zero.
+
+ Logs and warns about zero-valued parameters.
+
+ Returns
+ -------
+ None
+ """
+ zeros = [p for p in self.model.parameters if np.isclose(p.value, 0.0)]
+ if zeros:
+ msg = f"Zero-valued Parameters: {[p.name for p in zeros]}"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
+ print(f"⚠️ {msg}") # Print to console for visibility
+
+ def check_missing_initial_conditions(self):
+ """
+ Check for monomers missing initial conditions.
+
+ Logs and warns about monomers that do not have initial conditions defined.
+
+ Returns
+ -------
+ None
+ """
+ defined = list()
+ for initial in self.model.initials:
+ for m in monomers_from_pattern(initial.pattern):
+ defined.append(m.name)
+ defined = set(defined)
+ all_monomers = set(m.name for m in self.model.monomers)
+ missing = all_monomers - defined
+ if missing:
+ msg = f"Monomers missing initial conditions: {list(missing)}"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
+ print(f"⚠️ {msg}") # Print to console for visibility
+
+ def check_dangling_reused_bonds(self):
+ """
+ Check for dangling or reused bonds in all rules.
+
+ Returns
+ -------
+ None
+ """
+ for rule in self.model.rules:
+ try:
+ check_dangling_bonds(rule.rule_expression.reactant_pattern)
+ except Exception as e:
+ msg = f"Error checking reactant pattern in rule '{rule.name}': {e}"
+ self.logger.error(msg)
+ warnings.warn(msg, category=UserWarning)
+ print(msg) # Print to console for visibility
+ if rule.is_reversible:
+ try:
+ check_dangling_bonds(rule.rule_expression.product_pattern)
+ except Exception as e:
+ msg = f"Error checking product pattern in rule '{rule.name}': {e}"
+ self.logger.error(msg)
+ warnings.warn(msg, category=UserWarning)
+ print(msg) # Print to console for visibility
+
+ def check_equations_generation(self):
+ """
+ Run the `generate_equations` function on the model and capture and report any errors.
+
+ Returns
+ -------
+ None
+ """
+ try:
+ generate_equations(self.model)
+ self.logger.info("Model equations generated successfully.")
+ except Exception as e:
+ msg = f"Error generating model equations: {e}"
+ self.logger.error(msg)
+ warnings.warn(msg, category=UserWarning)
+ print(msg) # Print to console for visibility
+
+ @log_event()
+ def check_units(self):
+ """
+ Check for unit consistency in the model.
+
+ Returns
+ -------
+ None
+ """
+ units_check(self.model)
+
+ def check_unbound_sites(self):
+ """
+ Check for sites that never participate in bonds.
+
+ Logs and warns about unbound sites.
+
+ Returns
+ -------
+ None
+ """
+ bound_sites = set()
+ for r in self.model.rules:
+ for cp in r.rule_expression().all_complex_patterns():
+ for m in cp.monomer_patterns:
+ for site, state in m.site_conditions.items():
+ if isinstance(state, tuple): # bond tuple
+ bound_sites.add((m.monomer.name, site))
+
+ unbound = []
+ for m in self.model.monomers:
+ for site in m.sites:
+ if (m.name, site) not in bound_sites:
+ unbound.append(f"{m.name}.{site}")
+
+ if unbound:
+ msg = f"Unbound Sites (never participate in bonds): {unbound}"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
+
+ def check_overdefined_rules(self):
+ """
+ Check for rules that define the same reaction more than once.
+
+ Logs and warns about overdefined rules.
+
+ Returns
+ -------
+ None
+ """
+ seen = {}
+ for r in self.model.rules:
+ rxn = str(r.rule_expression())
+ if rxn in seen:
+ msg = f"Overdefined reaction: '{rxn}' in rules `{seen[rxn]}` and `{r.name}`"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
+ else:
+ seen[rxn] = r.name
+
+ def check_unreferenced_expressions(self):
+ """
+ Check for expressions that are not referenced by any rule or observable.
+
+ Logs and warns about unreferenced expressions.
+
+ Returns
+ -------
+ None
+ """
+ used = set()
+ for rule in self.model.rules:
+ if rule.rate_forward:
+ used.update(str(p) for p in rule.rate_forward.parameters)
+ if rule.rate_reverse:
+ used.update(str(p) for p in rule.rate_reverse.parameters)
+ for o in self.model.observables:
+ used.update(str(p) for p in o.function.atoms(Parameter))
+
+ exprs = [e.name for e in self.model.expressions if e.name not in used]
+ if exprs:
+ msg = f"Unreferenced Expressions: {exprs}"
+ self.logger.warning(f"⚠️ {msg}")
+ warnings.warn(msg, category=UserWarning)
diff --git a/tests/test_full_model.py b/tests/test_full_model.py
new file mode 100644
index 0000000..9391add
--- /dev/null
+++ b/tests/test_full_model.py
@@ -0,0 +1,102 @@
+import pytest
+
+from qspy.core import (
+ Model,
+ Parameter,
+ Monomer,
+ Parameter,
+ Expression,
+ Rule,
+ Compartment,
+ Observable,
+)
+from qspy.contexts import (
+ parameters,
+ expressions,
+ compartments,
+ monomers,
+ initials,
+ rules,
+ observables,
+)
+from qspy.functionaltags import PROTEIN
+from qspy.validation import ModelMetadataTracker
+from qspy.validation import ModelChecker
+
+__version__ = "test-0.1.0"
+__author__ = "test_author"
+
+Model().with_units(concentration="mg/L", time="h", volume="L")
+
+print("----------parameters-------------")
+
+with parameters():
+ V_1 = (10.0, "L")
+ A_0 = (100.0, "mg")
+ k_deg = (1e-3, "1/s")
+ k_goo = (0, "1/s")
+
+print("----------expressions-------------")
+
+with expressions():
+ C_0 = A_0 / V_1
+ C_1 = 2.0 * C_0
+
+print("----------compartments-------------")
+with compartments():
+ CENTRAL = V_1
+
+print("----------monomers-------------")
+with monomers():
+ molec_a = (None, None)
+ molec_b = (["b"], None, PROTEIN.ANTIBODY)
+ molec_c = (["b", "state"], {"state": ["p", "u"]})
+
+molec_a @= PROTEIN.PHOSPHATASE
+Monomer("molec_d", ["b"]) @ PROTEIN.RECEPTOR
+
+print("----------initials-------------")
+with initials():
+ molec_a() ** CENTRAL << C_0
+
+
+print("----------rules-------------")
+with rules():
+ R_1 = (molec_a() ** CENTRAL >> None, k_deg)
+
+print("----------observables-------------")
+with observables():
+ ~(molec_a() ** CENTRAL)
+ ~molec_c(b=None, state="u")
+ molec_c(b=None, state="p") > "C_p"
+
+ModelMetadataTracker(__version__, author=__author__)
+ModelChecker()
+
+
+@pytest.mark.integration
+def test_full_model():
+ """
+ Test the full model creation and validation process.
+ This function initializes a model, sets up metadata tracking,
+ and runs the model checker to validate the model.
+ """
+ print(model)
+ assert len(model.parameters) > 0, "Model should have parameters defined"
+ assert len(model.monomers) > 0, "Model should have monomers defined"
+ assert len(model.rules) > 0, "Model should have rules defined"
+ assert len(model.observables) > 0, "Model should have observables defined"
+ assert model.qspy_metadata_tracker is not None, (
+ "Model should have metadata tracker initialized"
+ )
+ assert model.monomers["molec_b"].functional_tag == PROTEIN.ANTIBODY, (
+ "Monomer 'molec_b' should have functional tag 'ANTIBODY'"
+ )
+ assert hasattr(model, "simulation_units"), (
+ "Model should have simulation units defined"
+ )
+
+
+if __name__ == "__main__":
+ test_full_model()
+ print("Full model test passed successfully.")