diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e58df8b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "plugins": { + "marketplaces": [ + "PolicyEngine/policyengine-claude" + ], + "auto_install": [ + "analysis-tools@policyengine-claude" + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d694392 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv pip install --system -e ".[dev]" + + - name: Run tests + run: | + uv run pytest tests/ -v --tb=short + + - name: Check formatting + if: matrix.python-version == '3.10' + run: | + pip install black isort + black --check givecalc/ tests/ ui/ --line-length 79 + isort --check givecalc/ tests/ ui/ diff --git a/.gitignore b/.gitignore index 3d35396..31fd191 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,16 @@ __pycache__/ # Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties` # should NOT be excluded as they contain compiler settings and other important # information for Eclipse / Flash Builder. + +# Python +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ +*.so +.DS_Store +.venv/ +venv/ diff --git a/.streamlit/config.toml b/.streamlit/config.toml index d0f1f55..69f3feb 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -22,7 +22,4 @@ toolbarMode = "minimal" showErrorDetails = true [browser] -gatherUsageStats = true - -[theme.button] -backgroundColor = "#39C6C0" \ No newline at end of file +gatherUsageStats = true \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9116f49 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,293 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +GiveCalc is a Streamlit web application that calculates how charitable giving affects taxes. It uses the PolicyEngine-US Python package to compute accurate federal and state tax impacts from charitable donations. Users input their income, filing status, state, and other deductions, and the app shows how donations reduce net taxes through deductions and credits. + +## Running the Application + +**Development:** +```bash +streamlit run app.py +``` + +The app will open in your browser at http://localhost:8501. + +**Installation:** +```bash +# Install package in editable mode +pip install -e . + +# Or use uv (recommended) +uv pip install --system -e . +``` + +**Testing:** +```bash +# Run tests with uv +uv run pytest tests/ -v + +# Or with standard pytest +pytest tests/ -v +``` + +Key dependencies: +- `streamlit`: Web application framework +- `policyengine-us>=1.155.0`: Tax and benefit calculations +- `plotly`: Interactive charts +- `pandas`, `numpy`: Data manipulation +- `scipy`: Optimization for target donation calculations + +## Architecture + +### Package Structure + +The codebase is organized into two main layers: + +1. **`givecalc/` package** - Core calculation logic (installable Python package) +2. **UI layer** (`ui/`, `app.py`, `visualization.py`, `tax_info.py`) - Streamlit interface + +### Application Flow + +1. **User Input** (`ui/basic.py`, `ui/donations.py`) + - State selection with NYC checkbox for NY residents + - Income, filing status, number of children + - Itemized deductions (mortgage interest, real estate taxes, medical expenses, casualty losses) + - Initial donation amount + +2. **Situation Creation** (`givecalc/core/situation.py`) + - Builds a PolicyEngine situation dictionary with all household members + - Creates entities: people, families, marital units, tax units, SPM units, households + - Adds an "axes" array that varies charitable donations from $0 to income in 1001 steps + - All calculations use CURRENT_YEAR (2024) from `givecalc/constants.py` + +3. **Tax Calculations** (`givecalc/calculations/tax.py`) + - `calculate_donation_metrics()`: Calculates metrics at a specific donation amount + - `calculate_donation_effects()`: Runs simulation across donation range using axes + - Returns DataFrames with income tax (net of benefits) and marginal savings rate + +4. **Visualization** (`visualization.py`) + - Net taxes vs donation amount line chart + - Marginal giving discount (tax savings per $1 donated) + - Net income after taxes and donations + - All charts use PolicyEngine branding (Roboto font, teal accent color) + +5. **Target Donation Calculator** (`ui/target_donation.py`, `givecalc/calculations/donations.py`) + - Users can specify a target net income reduction + - Uses scipy interpolation to find the required donation amount + - Shows comparison between current and required donation + +### Key Files + +**Package (givecalc/):** +- `givecalc/__init__.py`: Clean package API exposing all key functions +- `givecalc/constants.py`: Shared constants (CURRENT_YEAR, colors, default age) +- `givecalc/config.py`: Configuration file loading +- `givecalc/core/situation.py`: Creates PolicyEngine situation dictionaries +- `givecalc/core/simulation.py`: Creates single-point simulations (no axes) +- `givecalc/calculations/tax.py`: Tax calculation logic +- `givecalc/calculations/donations.py`: Target donation interpolation +- `givecalc/calculations/net_income.py`: Net income calculations + +**UI Layer:** +- `app.py`: Main Streamlit entry point, renders all UI sections +- `ui/basic.py`: Input widgets for income, state, filing status, deductions +- `ui/donations.py`: Donation input widgets +- `ui/tax_results.py`: Tax results display +- `ui/target_donation.py`: Target donation calculator UI +- `visualization.py`: Plotly chart generation +- `tax_info.py`: Displays federal and state tax program information +- `config.yaml`: State-specific tax program descriptions + +### Code Organization + +``` +givecalc/ +├── app.py # Main Streamlit app +├── visualization.py # Plotly chart generation +├── tax_info.py # Tax program info display +├── config.yaml # State tax program descriptions +├── requirements.txt # Python dependencies +├── requirements-dev.txt # Dev dependencies (pytest, etc.) +├── setup.py # Package installation configuration +├── pytest.ini # Pytest configuration +├── givecalc/ # Core calculation package +│ ├── __init__.py # Clean package API +│ ├── constants.py # Shared constants +│ ├── config.py # YAML config loader +│ ├── core/ # Core functionality +│ │ ├── situation.py # PolicyEngine situation builder +│ │ └── simulation.py # Single-point simulation helper +│ └── calculations/ # Calculation functions +│ ├── tax.py # Main tax calculations +│ ├── donations.py # Target donation interpolation +│ └── net_income.py # Net income calculations +├── ui/ # Streamlit UI components +│ ├── basic.py # Basic input widgets +│ ├── donations.py # Donation input widgets +│ ├── tax_results.py # Tax results display +│ └── target_donation.py # Target donation calculator +├── tests/ # Test suite (TDD) +│ ├── test_situation.py # Situation creation tests +│ ├── test_tax.py # Tax calculation tests +│ ├── test_donations.py # Donation calculation tests +│ └── test_simulation.py # Simulation tests +└── .streamlit/ + └── config.toml # Streamlit theme (teal accent) +``` + +## PolicyEngine Integration + +### Situation Dictionary Structure + +PolicyEngine requires a nested dictionary with these entities: +- `people`: Individuals with income, age, deductions +- `families`: Family groupings +- `marital_units`: Marriage-based groupings +- `tax_units`: Tax filing units +- `spm_units`: Supplemental Poverty Measure units +- `households`: Geographic/state information + +All entities must have consistent member lists. + +### Using Axes for Donation Sweeps + +The `axes` parameter creates multiple simulations varying one or more parameters: + +```python +"axes": [[{ + "name": "charitable_cash_donations", + "count": 1001, + "min": 0, + "max": employment_income, + "period": CURRENT_YEAR, +}]] +``` + +This creates 1001 simulations with donations from $0 to income. When you call `simulation.calculate()`, you get an array of 1001 values. + +### Key Variables + +- `household_tax`: Total taxes (federal + state + local) +- `household_benefits`: Total benefits (EITC, CTC, SNAP, etc.) +- `household_net_income`: Income minus taxes plus benefits +- Net taxes = `household_tax - household_benefits` + +### State Handling + +- State is specified via `state_name` in the household entity (two-letter code) +- NYC residents need `in_nyc: True` for NYC-specific taxes +- State-specific deductions/credits are automatically calculated + +## Key Patterns + +### Creating a Single-Point Simulation + +Use `create_donation_simulation()` for calculating metrics at a specific donation: + +```python +from donation_simulation import create_donation_simulation + +simulation = create_donation_simulation(situation, donation_amount=5000) +net_tax = simulation.calculate("household_tax", CURRENT_YEAR) - \ + simulation.calculate("household_benefits", CURRENT_YEAR) +``` + +### Creating a Donation Sweep + +Use the situation with axes directly: + +```python +from policyengine_us import Simulation + +simulation = Simulation(situation=situation) # situation has axes +donations = simulation.calculate("charitable_cash_donations", map_to="household") +taxes = simulation.calculate("household_tax", map_to="household") +``` + +### Adding New Deductions + +1. Add parameter to `create_situation()` in `situation.py` +2. Add to person's dictionary (usually "you") +3. Add input widget in `ui/basic.py` or appropriate UI module +4. Update `render_itemized_deductions()` to return the new value +5. Pass through in `app.py` when creating situation + +### Marginal Tax Savings Calculation + +The marginal savings rate is calculated using numpy's gradient: + +```python +df["marginal_savings"] = -np.gradient(df.income_tax_after_donations) / \ + np.gradient(df[donation_column]) +``` + +This gives the tax reduction per dollar donated (e.g., 0.24 = 24¢ saved per $1 donated). + +## Styling + +### Colors (from constants.py) +- `TEAL_ACCENT = "#39C6C0"`: Primary UI accent color +- `BLUE_PRIMARY = "#2C6496"`: Chart color (not currently used) + +### Fonts +- UI: Roboto (loaded via Google Fonts in app.py) +- Charts: Roboto Serif (set in visualization.py) + +### Streamlit Theme +See `.streamlit/config.toml` for theme configuration with teal accent. + +### Chart Formatting +All charts use `format_fig()` in `visualization.py` which: +- Applies PolicyEngine branding +- Adds PolicyEngine logo +- Sets Roboto Serif font +- Uses white background +- Formats axes with currency and percentage + +## State Tax Programs + +The `config.yaml` file contains descriptions of federal and state-specific charitable deduction programs. When adding new state programs: + +1. Add entry under `state_programs` with two-letter state code +2. Include `title` and `description` fields +3. Update `tax_info.py` if new display logic is needed + +States with special charitable benefits: +- **AZ**: Dollar-for-dollar tax credit (up to $400-$800) +- **MS**: Foster care charitable tax credit +- **VT**: 5% credit on first $20k in contributions +- **CO**: Subtraction for contributions over $500 with standard deduction +- **NH**: 85% education tax credit + +## PolicyEngine-US Version + +The app requires `policyengine-us>=1.155.0` for accurate 2024 calculations. The current version is displayed in the app footer (see `constants.py` for version detection logic). + +## Development Tips + +### Testing Changes Locally + +1. Make code changes +2. Streamlit auto-reloads when files change +3. If auto-reload fails, refresh browser or restart `streamlit run app.py` + +### Debugging PolicyEngine Issues + +Add these lines to see calculation details: + +```python +print(simulation.calculate("variable_name", CURRENT_YEAR)) +simulation.trace = True # Enables detailed calculation tracing +``` + +### Common Gotchas + +1. **Situation modifications**: Always copy situations before modifying to avoid side effects +2. **Member lists**: All entities (family, marital_unit, tax_unit, etc.) must have same members +3. **Year parameter**: Always use `CURRENT_YEAR` constant for consistency +4. **Axes removal**: Remove axes before creating single-point simulations +5. **Net taxes**: Remember to subtract benefits from taxes: `household_tax - household_benefits` +6. **NYC checkbox**: Only show for NY state residents (handled in `ui/basic.py`) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e19ff36 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +.PHONY: test run format install clean + +# Run tests +test: + uv run pytest tests/ -v + +# Run tests with coverage +test-cov: + uv run pytest tests/ -v --cov=givecalc --cov-report=term-missing + +# Run specific test +test-one: + uv run pytest $(TEST) -v -s + +# Run the Streamlit app +run: + streamlit run app.py + +# Format code +format: + black givecalc/ tests/ ui/ --line-length 79 + isort givecalc/ tests/ ui/ + +# Install package in editable mode +install: + uv pip install --system -e . + +# Install with dev dependencies +install-dev: + uv pip install --system -e ".[dev]" + +# Clean build artifacts +clean: + rm -rf build/ dist/ *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + rm -rf .pytest_cache + +# Run performance test +perf: + uv run pytest tests/test_performance.py -v -s + +# Update dependencies +update: + pip install --upgrade policyengine-us policyengine-core diff --git a/UI_IMPROVEMENTS.md b/UI_IMPROVEMENTS.md new file mode 100644 index 0000000..2f56a33 --- /dev/null +++ b/UI_IMPROVEMENTS.md @@ -0,0 +1,68 @@ +# UI Improvement Ideas for GiveCalc + +## Already Implemented ✅ +- Spinners during calculations +- Two-column input layout +- Success messages +- Better button styling +- Target donation in collapsible section + +## Future Enhancements + +### Performance +- **Caching** - Use `@st.cache_data` to cache calculation results + - Cache `create_situation()` based on inputs + - Cache PolicyEngine calculations + - Will make UI more responsive + +### User Experience +- **Pre-filled examples** - Add "Try example" buttons (e.g., "Family of 4, $100k income") +- **Input validation** - Show warnings for unusual inputs +- **Keyboard shortcuts** - Enter key to calculate +- **URL state** - Save inputs in URL for sharing scenarios +- **Download results** - Export calculations as PDF/CSV + +### Visualization +- **Interactive charts** - Click chart to update donation amount +- **Comparison view** - Side-by-side comparison of different scenarios +- **Progress bar** - Show calculation progress (1001 simulations) +- **Animated transitions** - Smooth transitions between results + +### Educational +- **Tooltips** - Explain tax terms (EITC, standard deduction, etc.) +- **Example scenarios** - "What others like you donate" +- **Tax brackets visual** - Show which bracket user is in +- **Effective tax rate** - Display prominently + +### Mobile +- **Responsive design** - Better mobile layout +- **Touch-friendly inputs** - Larger tap targets +- **Simplified mobile view** - Hide advanced options by default + +### Accessibility +- **Keyboard navigation** - Full keyboard support +- **Screen reader support** - ARIA labels +- **High contrast mode** - For vision impairment +- **Font size controls** - User-adjustable text size + +### Advanced Features +- **Multi-year planning** - Project donations over multiple years +- **Scenario comparison** - Compare 2-3 donation amounts side by side +- **State comparison** - How would taxes differ in another state? +- **Investment income** - Add capital gains, dividends +- **Retirement contributions** - Include 401k, IRA +- **Save scenarios** - Login to save/load scenarios + +### Polish +- **Loading skeletons** - Show chart placeholders while calculating +- **Error messages** - User-friendly error handling +- **Help documentation** - Inline help/FAQ +- **Guided tour** - First-time user walkthrough +- **Dark mode** - Optional dark theme + +## Quick Wins (30 min each) +1. Add @st.cache_data to expensive functions +2. Add "Reset" button to clear inputs +3. Add footer with version number and last updated date +4. Add keyboard shortcut (Ctrl+Enter to calculate) +5. Add loading skeletons for charts diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app.py b/app.py index a9a6a52..cabdb91 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,12 @@ import streamlit as st -from config import load_config -from constants import TEAL_ACCENT, MARGIN +from givecalc import ( + load_config, + TEAL_ACCENT, + MARGIN, + create_situation, + calculate_donation_metrics, + calculate_donation_effects, +) from ui.basic import ( render_state_selector, render_income_input, @@ -11,12 +17,34 @@ from ui.donations import render_initial_donation, render_policyengine_donate from ui.tax_results import render_tax_results from ui.target_donation import render_target_donation_section -from calculations.tax import ( - calculate_donation_metrics, - calculate_donation_effects, -) -from tax_info import display_tax_programs -from situation import create_situation +from ui.tax_info import display_tax_programs + + +@st.cache_data(show_spinner=False) +def cached_calculate_effects( + income, is_married, state_code, in_nyc, num_children, + mortgage_interest, real_estate_taxes, medical_expenses, casualty_loss, + donation_amount +): + """Cache expensive calculations to improve performance.""" + situation = create_situation( + income, + is_married=is_married, + state_code=state_code, + in_nyc=in_nyc, + num_children=num_children, + mortgage_interest=mortgage_interest, + real_estate_taxes=real_estate_taxes, + medical_out_of_pocket_expenses=medical_expenses, + casualty_loss=casualty_loss, + ) + + baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) + current_metrics = calculate_donation_metrics(situation, donation_amount) + plus100_metrics = calculate_donation_metrics(situation, donation_amount + MARGIN) + df = calculate_donation_effects(situation) + + return situation, baseline_metrics, current_metrics, plus100_metrics, df def main(): @@ -53,68 +81,112 @@ def main(): st.markdown( f"Calculate how charitable giving affects your taxes. [Read our explainer to learn more.](https://policyengine.org/us/research/givecalc)" ) - st.divider() # Load configuration config = load_config() - # Basic information - state, in_nyc = render_state_selector(config["states"]) - income = render_income_input() - is_married, num_children = render_personal_info() - deductions = render_itemized_deductions() - donation_amount = render_initial_donation(income) - donation_in_mind = st.checkbox( - "Would you like to target a donation level based on net income reduction?" - ) + # Sidebar for all inputs wrapped in form + with st.sidebar: + with st.form(key="input_form"): + st.header("📋 Your Information") - if donation_in_mind: - st.expander("Calculate a target donation") - # Add radio button for percentage vs dollar amount - reduction_type = st.radio( - "How would you like to reduce your net income?", - options=["Percentage", "Dollar amount"], - horizontal=True, - index=0, # Default to percentage - ) + state, in_nyc = render_state_selector(config["states"]) + income = render_income_input() + is_married, num_children = render_personal_info() + deductions = render_itemized_deductions() - # Condensed input field for reduction amount with one decimal point - reduction_amount = st.number_input( - f"Enter reduction amount ({'%' if reduction_type == 'Percentage' else '$'}):", - min_value=0.0, # Always use float for min_value - max_value=( - 100.0 if reduction_type == "Percentage" else float(income) - ), # Convert income to float - value=( - 10.0 if reduction_type == "Percentage" else float(min(1000, income)) - ), # Convert to float - step=( - 0.1 if reduction_type == "Percentage" else 100.0 - ), # Use float for step values - format="%.1f", # Consistent format for both cases - help=f"Enter the reduction in {'percentage' if reduction_type == 'Percentage' else 'dollars'}.", - ) + st.divider() - if st.button("Calculate tax implications", type="primary"): + # Mode selection + calc_mode = st.radio( + "Calculation mode:", + options=["Enter donation amount", "Target net income reduction"], + help="Choose how you want to calculate", + ) + + if calc_mode == "Enter donation amount": + donation_amount = st.number_input( + "How much would you like to donate? ($)", + min_value=0, + max_value=income, + value=min(1000, income), + step=100, + help="Enter the amount of cash donations you plan to make to charity", + ) + # Validate donation amount + if donation_amount > income: + st.warning("⚠️ Donation exceeds income. Results may not be realistic.") + + donation_in_mind = False + reduction_amount = None + reduction_type = None + else: + # Target reduction mode + st.caption("Find the donation needed to achieve your target reduction") + + reduction_type = st.radio( + "Reduce by:", + options=["Percentage", "Dollar amount"], + horizontal=True, + index=0, + ) + + if reduction_type == "Percentage": + reduction_amount = st.slider( + "Target reduction (%):", + min_value=0.0, + max_value=50.0, + value=10.0, + step=0.5, + format="%.1f%%", + ) + else: + reduction_amount = st.number_input( + "Target reduction ($):", + min_value=0, + max_value=int(income), + value=min(10000, int(income * 0.1)), + step=1000, + format="%d", + ) + + donation_in_mind = True + # Set a default donation amount for the calculations + donation_amount = min(1000, income) + + st.divider() + + # Form submit button + calculate_clicked = st.form_submit_button( + "🧮 Calculate", + type="primary", + use_container_width=True, + ) + + # Show results when form is submitted + if calculate_clicked: + with st.spinner("🧮 Calculating your tax implications..."): + # Use cached calculations for better performance + ( + situation, + baseline_metrics, + current_donation_metrics, + current_donation_plus100_metrics, + df, + ) = cached_calculate_effects( + income, + is_married, + state, + in_nyc, + num_children, + deductions["mortgage_interest"], + deductions["real_estate_taxes"], + deductions["medical_out_of_pocket_expenses"], + deductions["casualty_loss"], + donation_amount, + ) - # Calculate baseline metrics once - situation = create_situation( - income, - is_married=is_married, - state_code=state, - in_nyc=in_nyc, - num_children=num_children, - **deductions, - ) - baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) - current_donation_metrics = calculate_donation_metrics( - situation, donation_amount - ) - current_donation_plus100_metrics = calculate_donation_metrics( - situation, donation_amount + MARGIN - ) - df = calculate_donation_effects(situation) # Render main sections render_tax_results( df, @@ -124,17 +196,19 @@ def main(): current_donation_metrics, current_donation_plus100_metrics, ) - if donation_in_mind: - render_target_donation_section( - df, - baseline_metrics, - income, - donation_amount, - current_donation_metrics, - situation, - reduction_amount, - reduction_type, - ) + + if donation_in_mind and reduction_amount is not None: + with st.spinner("🎯 Finding your target donation amount..."): + render_target_donation_section( + df, + baseline_metrics, + income, + donation_amount, + current_donation_metrics, + situation, + reduction_amount, + reduction_type, + ) # Display tax program information st.divider() diff --git a/constants.py b/constants.py deleted file mode 100644 index ed9dcaf..0000000 --- a/constants.py +++ /dev/null @@ -1,13 +0,0 @@ -import pkg_resources - -# Get PolicyEngine-US version -try: - PE_VERSION = pkg_resources.get_distribution("policyengine-us").version -except pkg_resources.DistributionNotFound: - PE_VERSION = "unknown" - -CURRENT_YEAR = 2024 # Year for all calculations -BLUE_PRIMARY = "#2C6496" # Chart color -TEAL_ACCENT = "#39C6C0" # Text highlight color -DEFAULT_AGE = 30 # Default age for all calculations -MARGIN = 100 # Margin for all calculations diff --git a/givecalc/README.md b/givecalc/README.md new file mode 100644 index 0000000..ce51411 --- /dev/null +++ b/givecalc/README.md @@ -0,0 +1,58 @@ +# GiveCalc Package + +Core calculation logic for the GiveCalc application, separated from the UI layer. + +## Installation + +```bash +pip install -e . +``` + +## Usage + +```python +from givecalc import ( + create_situation, + calculate_donation_metrics, + calculate_donation_effects, + calculate_target_donation, +) + +# Create a situation +situation = create_situation( + employment_income=100000, + is_married=True, + state_code="CA", + num_children=2 +) + +# Calculate metrics at a specific donation amount +metrics = calculate_donation_metrics(situation, donation_amount=5000) + +# Calculate effects across donation range +df = calculate_donation_effects(situation) + +# Find donation to achieve target net income reduction +baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) +required_donation, _, _, _ = calculate_target_donation( + situation, df, baseline_metrics, target_reduction=10000 +) +``` + +## Testing + +```bash +uv run pytest tests/ +``` + +## Package Structure + +- `constants.py` - Shared constants (CURRENT_YEAR, colors, etc.) +- `config.py` - Configuration file loading +- `core/` + - `situation.py` - PolicyEngine situation creation + - `simulation.py` - Single-point donation simulations +- `calculations/` + - `tax.py` - Tax calculation functions + - `donations.py` - Target donation calculations + - `net_income.py` - Net income calculations diff --git a/givecalc/__init__.py b/givecalc/__init__.py new file mode 100644 index 0000000..75b509a --- /dev/null +++ b/givecalc/__init__.py @@ -0,0 +1,47 @@ +"""GiveCalc - Calculate how charitable giving affects taxes. + +This package provides the core calculation logic for the GiveCalc application, +separated from the Streamlit UI layer. +""" + +from givecalc.constants import ( + CURRENT_YEAR, + BLUE_PRIMARY, + TEAL_ACCENT, + DEFAULT_AGE, + MARGIN, +) +from givecalc.core.situation import create_situation +from givecalc.core.simulation import create_donation_simulation +from givecalc.calculations.tax import ( + calculate_donation_metrics, + calculate_donation_effects, + create_donation_dataframe, +) +from givecalc.calculations.donations import calculate_target_donation +from givecalc.calculations.net_income import add_net_income_columns +from givecalc.config import load_config + +__version__ = "0.1.0" + +__all__ = [ + # Constants + "CURRENT_YEAR", + "BLUE_PRIMARY", + "TEAL_ACCENT", + "DEFAULT_AGE", + "MARGIN", + # Core functions + "create_situation", + "create_donation_simulation", + # Tax calculations + "calculate_donation_metrics", + "calculate_donation_effects", + "create_donation_dataframe", + # Donation calculations + "calculate_target_donation", + # Net income calculations + "add_net_income_columns", + # Config + "load_config", +] diff --git a/givecalc/calculations/__init__.py b/givecalc/calculations/__init__.py new file mode 100644 index 0000000..cd12a03 --- /dev/null +++ b/givecalc/calculations/__init__.py @@ -0,0 +1,17 @@ +"""Calculation functions for taxes, donations, and net income.""" + +from givecalc.calculations.tax import ( + calculate_donation_metrics, + calculate_donation_effects, + create_donation_dataframe, +) +from givecalc.calculations.donations import calculate_target_donation +from givecalc.calculations.net_income import add_net_income_columns + +__all__ = [ + "calculate_donation_metrics", + "calculate_donation_effects", + "create_donation_dataframe", + "calculate_target_donation", + "add_net_income_columns", +] diff --git a/calculations/donations.py b/givecalc/calculations/donations.py similarity index 84% rename from calculations/donations.py rename to givecalc/calculations/donations.py index d72f2c8..d61fb52 100644 --- a/calculations/donations.py +++ b/givecalc/calculations/donations.py @@ -1,5 +1,5 @@ from scipy.interpolate import interp1d -from calculations.tax import calculate_donation_metrics +from givecalc.calculations.tax import calculate_donation_metrics def calculate_target_donation( @@ -57,8 +57,8 @@ def calculate_target_donation( fill_value=(df["reduction_percentage"].min(), df["reduction_percentage"].max()), ) - # Calculate interpolated values - required_donation = float(f_donation(target_amount)) + # Calculate interpolated values (use .item() to avoid numpy deprecation warning) + required_donation = float(f_donation(target_amount).item() if hasattr(f_donation(target_amount), 'item') else f_donation(target_amount)) required_donation_metrics = calculate_donation_metrics(situation, required_donation) required_donation_net_income = ( required_donation_metrics["baseline_net_income"][0] - required_donation @@ -67,7 +67,7 @@ def calculate_target_donation( actual_reduction = ( target_amount # Since we're interpolating, we can achieve the exact target ) - actual_percentage = float(f_percentage(target_amount)) + actual_percentage = float(f_percentage(target_amount).item() if hasattr(f_percentage(target_amount), 'item') else f_percentage(target_amount)) return ( required_donation, diff --git a/calculations/net_income.py b/givecalc/calculations/net_income.py similarity index 100% rename from calculations/net_income.py rename to givecalc/calculations/net_income.py diff --git a/calculations/tax.py b/givecalc/calculations/tax.py similarity index 80% rename from calculations/tax.py rename to givecalc/calculations/tax.py index 55a7691..7e5a2a6 100644 --- a/calculations/tax.py +++ b/givecalc/calculations/tax.py @@ -1,8 +1,8 @@ import numpy as np import pandas as pd from policyengine_us import Simulation -from constants import CURRENT_YEAR -from donation_simulation import create_donation_simulation +from givecalc.constants import CURRENT_YEAR +from givecalc.core.simulation import create_donation_simulation def calculate_donation_metrics(situation, donation_amount): @@ -45,10 +45,13 @@ def calculate_donation_effects(situation): simulation = Simulation(situation=situation) # Note: We add this as a column to enable non-cash donations in the future. donation_column = "charitable_cash_donations" - donations = simulation.calculate(donation_column, map_to="household") + # Use tax_unit for donations (where deductions are claimed) instead of household + # Person-level variable without aggregation formula doesn't map properly to household + donations = simulation.calculate(donation_column, period=CURRENT_YEAR, map_to="tax_unit") + income_tax_by_donation = simulation.calculate( - "household_tax", map_to="household" - ) - simulation.calculate("household_benefits", map_to="household") + "household_tax", period=CURRENT_YEAR, map_to="household" + ) - simulation.calculate("household_benefits", period=CURRENT_YEAR, map_to="household") return create_donation_dataframe(donations, income_tax_by_donation, donation_column) diff --git a/config.py b/givecalc/config.py similarity index 100% rename from config.py rename to givecalc/config.py diff --git a/givecalc/constants.py b/givecalc/constants.py new file mode 100644 index 0000000..8577dac --- /dev/null +++ b/givecalc/constants.py @@ -0,0 +1,16 @@ +try: + from importlib.metadata import version, PackageNotFoundError +except ImportError: + from importlib_metadata import version, PackageNotFoundError + +# Get PolicyEngine-US version +try: + PE_VERSION = version("policyengine-us") +except PackageNotFoundError: + PE_VERSION = "unknown" + +CURRENT_YEAR = 2025 # Year for all calculations +BLUE_PRIMARY = "#2C6496" # Chart color +TEAL_ACCENT = "#39C6C0" # Text highlight color +DEFAULT_AGE = 30 # Default age for all calculations +MARGIN = 100 # Margin for all calculations diff --git a/givecalc/core/__init__.py b/givecalc/core/__init__.py new file mode 100644 index 0000000..44a867f --- /dev/null +++ b/givecalc/core/__init__.py @@ -0,0 +1,6 @@ +"""Core functionality for situation and simulation creation.""" + +from givecalc.core.situation import create_situation +from givecalc.core.simulation import create_donation_simulation + +__all__ = ["create_situation", "create_donation_simulation"] diff --git a/donation_simulation.py b/givecalc/core/simulation.py similarity index 97% rename from donation_simulation.py rename to givecalc/core/simulation.py index f88a4c3..02bbbc0 100644 --- a/donation_simulation.py +++ b/givecalc/core/simulation.py @@ -1,5 +1,5 @@ from policyengine_us import Simulation -from constants import CURRENT_YEAR +from givecalc.constants import CURRENT_YEAR def create_donation_simulation(situation, donation_amount): diff --git a/situation.py b/givecalc/core/situation.py similarity index 96% rename from situation.py rename to givecalc/core/situation.py index 4b0ab75..bf39d6d 100644 --- a/situation.py +++ b/givecalc/core/situation.py @@ -1,5 +1,5 @@ from policyengine_us import Simulation -from constants import CURRENT_YEAR, DEFAULT_AGE +from givecalc.constants import CURRENT_YEAR, DEFAULT_AGE def create_situation( @@ -35,6 +35,7 @@ def create_situation( "you": { "age": {CURRENT_YEAR: DEFAULT_AGE}, "employment_income": {CURRENT_YEAR: employment_income}, + "charitable_cash_donations": {CURRENT_YEAR: 0}, # Initialize for axes "mortgage_interest": {CURRENT_YEAR: mortgage_interest}, "real_estate_taxes": {CURRENT_YEAR: real_estate_taxes}, "medical_out_of_pocket_expenses": { diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f358aa3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "givecalc" +version = "0.1.0" +description = "Calculate how charitable giving affects taxes" +readme = "README.md" +requires-python = ">=3.10,<3.13" +license = {text = "MIT"} +authors = [ + {name = "PolicyEngine", email = "hello@policyengine.org"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "streamlit", + "policyengine-us>=1.155.0", + "plotly", + "numpy", + "pandas", + "pyyaml", + "scipy", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=22.0.0", + "isort>=5.10.0", +] + +[project.urls] +Homepage = "https://github.com/PolicyEngine/givecalc" +Repository = "https://github.com/PolicyEngine/givecalc" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" + +[tool.black] +line-length = 79 +target-version = ["py38", "py39", "py310", "py311", "py312"] + +[tool.isort] +profile = "black" +line_length = 79 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9855d94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3bb0a2b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=7.0.0 +pytest-cov>=4.0.0 +black>=22.0.0 +isort>=5.10.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..353f20a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the givecalc package.""" diff --git a/tests/test_axes.py b/tests/test_axes.py new file mode 100644 index 0000000..1fa09ea --- /dev/null +++ b/tests/test_axes.py @@ -0,0 +1,33 @@ +"""Test that axes work correctly.""" + +from policyengine_us import Simulation +from givecalc import create_situation, CURRENT_YEAR + + +def test_axes_generate_donation_range(): + """Test that axes parameter actually generates different donation amounts.""" + situation = create_situation(employment_income=100000) + + print(f"\nSituation axes: {situation['axes']}") + + # Create simulation + sim = Simulation(situation=situation) + + # Try calculating on person level first + donations_person = sim.calculate("charitable_cash_donations", CURRENT_YEAR) + print(f"\nDonations (person level):") + print(f" Shape: {donations_person.shape}") + print(f" Min: ${donations_person.min():,.0f}, Max: ${donations_person.max():,.0f}") + + # Calculate on tax_unit level + donations = sim.calculate("charitable_cash_donations", CURRENT_YEAR, map_to="tax_unit") + print(f"\nDonations (tax_unit level):") + print(f" Shape: {donations.shape}") + print(f" Min: ${donations.min():,.0f}, Max: ${donations.max():,.0f}") + print(f" First 5: {donations[:5]}") + print(f" Last 5: {donations[-5:]}") + + # Donations should range from 0 to 100000 + assert donations.min() == 0, f"Min should be 0, got {donations.min()}" + assert donations.max() == 100000, f"Max should be 100000, got {donations.max()}" + assert len(donations) == 1001, f"Should have 1001 values, got {len(donations)}" diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..e4c44eb --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,47 @@ +"""Quick debug test to check data generation.""" + +from givecalc import create_situation, calculate_donation_effects + + +def test_check_dataframe_values(): + """Debug test to see what values are being generated.""" + situation = create_situation( + employment_income=500000, + is_married=True, + state_code="NY", + in_nyc=True, + ) + + df = calculate_donation_effects(situation) + + print("\n" + "=" * 60) + print("DATAFRAME DEBUG OUTPUT") + print("=" * 60) + print(f"Shape: {df.shape}") + print(f"\nColumns: {df.columns.tolist()}") + print(f"\nDonation range: ${df['charitable_cash_donations'].min():,.0f} - ${df['charitable_cash_donations'].max():,.0f}") + print(f"\nIncome tax stats:") + print(f" Min: ${df['income_tax'].min():,.2f}") + print(f" Max: ${df['income_tax'].max():,.2f}") + print(f" Range: ${df['income_tax'].max() - df['income_tax'].min():,.2f}") + print(f"\nIncome tax after donations stats:") + print(f" Min: ${df['income_tax_after_donations'].min():,.2f}") + print(f" Max: ${df['income_tax_after_donations'].max():,.2f}") + print(f" Range: ${df['income_tax_after_donations'].max() - df['income_tax_after_donations'].min():,.2f}") + print(f"\nMarginal savings stats:") + print(f" Min: {df['marginal_savings'].min():.4f}") + print(f" Max: {df['marginal_savings'].max():.4f}") + print(f" Mean: {df['marginal_savings'].mean():.4f}") + + print("\nFirst 5 rows:") + print(df.head()) + + print("\nRows around $30k donation (rows 60-65):") + print(df.iloc[60:65]) + + print("\nLast 5 rows:") + print(df.tail()) + print("=" * 60 + "\n") + + # This test always passes - it's just for inspection + assert True diff --git a/tests/test_donations.py b/tests/test_donations.py new file mode 100644 index 0000000..428b6fd --- /dev/null +++ b/tests/test_donations.py @@ -0,0 +1,115 @@ +"""Tests for donation calculation functions.""" + +import pytest +import pandas as pd +import numpy as np +from givecalc import ( + CURRENT_YEAR, + create_situation, + calculate_donation_metrics, + calculate_donation_effects, + calculate_target_donation, + add_net_income_columns, +) + + +def test_calculate_target_donation_by_amount(): + """Test calculating target donation by dollar amount.""" + + situation = create_situation(employment_income=100000) + baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) + df = calculate_donation_effects(situation) + + target_reduction = 5000 # Want to reduce net income by $5,000 + ( + required_donation, + required_net_income, + actual_reduction, + actual_percentage, + ) = calculate_target_donation( + situation, df, baseline_metrics, target_reduction, is_percentage=False + ) + + # Required donation should be positive + assert required_donation > 0 + # Required donation should be more than target reduction (due to tax benefits) + assert required_donation >= target_reduction + # Actual reduction should be close to target + assert abs(actual_reduction - target_reduction) < 100 + + +def test_calculate_target_donation_by_percentage(): + """Test calculating target donation by percentage.""" + situation = create_situation(employment_income=100000) + baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) + df = calculate_donation_effects(situation) + + target_percentage = 10 # Want to reduce net income by 10% + ( + required_donation, + required_net_income, + actual_reduction, + actual_percentage, + ) = calculate_target_donation( + situation, df, baseline_metrics, target_percentage, is_percentage=True + ) + + # Required donation should be positive + assert required_donation > 0 + # Actual percentage should be close to target + assert abs(actual_percentage - target_percentage) < 1.0 + + +def test_calculate_target_donation_zero_reduction(): + """Test calculating target donation with zero reduction.""" + situation = create_situation(employment_income=100000) + baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) + df = calculate_donation_effects(situation) + + target_reduction = 0 + ( + required_donation, + required_net_income, + actual_reduction, + actual_percentage, + ) = calculate_target_donation( + situation, df, baseline_metrics, target_reduction, is_percentage=False + ) + + # Required donation should be near zero + assert required_donation < 100 + + +def test_add_net_income_columns(): + """Test adding net income columns to DataFrame.""" + situation = create_situation(employment_income=100000) + baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) + df = calculate_donation_effects(situation) + + df_with_net = add_net_income_columns(df, baseline_metrics) + + # Check new columns exist + assert "tax_savings" in df_with_net.columns + assert "net_income" in df_with_net.columns + assert "net_income_reduction" in df_with_net.columns + assert "reduction_percentage" in df_with_net.columns + + # Check that net income decreases as donations increase + assert ( + df_with_net["net_income"].iloc[0] + > df_with_net["net_income"].iloc[-1] + ) + + +def test_add_net_income_columns_does_not_modify_original(): + """Test that add_net_income_columns doesn't modify original DataFrame.""" + situation = create_situation(employment_income=100000) + baseline_metrics = calculate_donation_metrics(situation, donation_amount=0) + df = calculate_donation_effects(situation) + + original_columns = df.columns.tolist() + df_with_net = add_net_income_columns(df, baseline_metrics) + + # Original should not have new columns + assert "net_income" not in df.columns + assert "net_income" in df_with_net.columns diff --git a/tests/test_multi_person_household.py b/tests/test_multi_person_household.py new file mode 100644 index 0000000..55aacbf --- /dev/null +++ b/tests/test_multi_person_household.py @@ -0,0 +1,49 @@ +"""Test axes work with multi-person households.""" + +from policyengine_us import Simulation +from givecalc import create_situation, calculate_donation_effects, CURRENT_YEAR + + +def test_axes_with_married_couple(): + """Test that axes work with married couple.""" + situation = create_situation( + employment_income=100000, + is_married=True, + ) + + df = calculate_donation_effects(situation) + + print(f"\nMarried couple:") + print(f" Donation range: ${df['charitable_cash_donations'].min():,.0f} - ${df['charitable_cash_donations'].max():,.0f}") + print(f" Tax range: ${df['income_tax'].min():,.0f} - ${df['income_tax'].max():,.0f}") + + # Donations should vary + assert df['charitable_cash_donations'].max() == 100000 + assert df['charitable_cash_donations'].min() == 0 + + # Taxes should vary too + tax_range = df['income_tax'].max() - df['income_tax'].min() + assert tax_range > 100, f"Tax should vary, got range of ${tax_range:,.0f}" + + +def test_axes_with_children(): + """Test that axes work with children.""" + situation = create_situation( + employment_income=100000, + is_married=True, + num_children=2, + ) + + df = calculate_donation_effects(situation) + + print(f"\nMarried with 2 children:") + print(f" Donation range: ${df['charitable_cash_donations'].min():,.0f} - ${df['charitable_cash_donations'].max():,.0f}") + print(f" Tax range: ${df['income_tax'].min():,.0f} - ${df['income_tax'].max():,.0f}") + + # Donations should vary + assert df['charitable_cash_donations'].max() == 100000 + assert df['charitable_cash_donations'].min() == 0 + + # Taxes should vary + tax_range = df['income_tax'].max() - df['income_tax'].min() + assert tax_range > 100, f"Tax should vary, got range of ${tax_range:,.0f}" diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..6c57c69 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,28 @@ +"""Performance tests for calculation speed.""" + +import time +from givecalc import create_situation, calculate_donation_effects + + +def test_calculation_speed(): + """Test that calculations complete in reasonable time.""" + situation = create_situation( + employment_income=500000, + is_married=True, + state_code="NY", + in_nyc=True, + ) + + start = time.time() + df = calculate_donation_effects(situation) + elapsed = time.time() - start + + print(f"\n⏱️ Calculated {len(df)} donation points in {elapsed:.2f} seconds") + print(f" ({len(df)/elapsed:.0f} points/second)") + + # Should complete in under 10 seconds for 1001 points + assert elapsed < 10, f"Too slow: {elapsed:.2f}s for {len(df)} points" + + # Check data is valid + assert len(df) == 1001 + assert "income_tax_after_donations" in df.columns diff --git a/tests/test_realistic_scenarios.py b/tests/test_realistic_scenarios.py new file mode 100644 index 0000000..83e22cf --- /dev/null +++ b/tests/test_realistic_scenarios.py @@ -0,0 +1,102 @@ +"""Tests for realistic user scenarios.""" + +import pytest +import pandas as pd +from givecalc import ( + create_situation, + calculate_donation_metrics, + calculate_donation_effects, +) + + +def test_high_income_nyc_scenario(): + """Test with realistic high-income NYC scenario: $500k income, $30k donation.""" + # Create situation matching user's scenario + situation = create_situation( + employment_income=500000, + is_married=True, + state_code="NY", + in_nyc=True, + num_children=0, + ) + + # Calculate effects + df = calculate_donation_effects(situation) + + # Check DataFrame structure + assert len(df) == 1001, "Should have 1001 donation points" + assert df["charitable_cash_donations"].min() == 0 + assert df["charitable_cash_donations"].max() == 500000 + + # Check that income tax varies meaningfully across donation range + tax_min = df["income_tax_after_donations"].min() + tax_max = df["income_tax_after_donations"].max() + tax_range = tax_max - tax_min + + print(f"\nDEBUG: Tax range: ${tax_min:,.0f} to ${tax_max:,.0f}") + print(f"Tax variation: ${tax_range:,.0f}") + + # For $500k income, tax should vary by at least $50k across donation range + # (rough estimate: 37% federal + 10% state = ~47% marginal rate on deductions) + # $500k in donations * 47% = ~$235k tax variation + assert tax_range > 50000, f"Tax range too small: ${tax_range:,.0f}" + + # Check specific donation point + metrics_30k = calculate_donation_metrics(situation, donation_amount=30000) + tax_at_30k = metrics_30k["baseline_income_tax"][0] + + print(f"Tax at $30k donation: ${tax_at_30k:,.0f}") + + # Tax at $30k donation should be positive for high earners + assert tax_at_30k > 0, "High earners should have positive tax burden" + + +def test_moderate_income_scenario(): + """Test with moderate income scenario that might have negative net taxes.""" + situation = create_situation( + employment_income=50000, + is_married=True, + state_code="CA", + num_children=2, + ) + + df = calculate_donation_effects(situation) + + # Check that calculations run + assert len(df) == 1001 + + # For moderate income with children, net taxes might be negative (due to credits) + # This is OK - just check that the data varies + tax_range = ( + df["income_tax_after_donations"].max() + - df["income_tax_after_donations"].min() + ) + + print(f"\nModerate income tax range: ${tax_range:,.0f}") + + # Should still have some variation (though small for moderate income with children) + assert tax_range > 1, "Should have some tax variation from donations" + + +def test_single_high_earner(): + """Test single high earner in high-tax state.""" + situation = create_situation( + employment_income=300000, + is_married=False, + state_code="CA", + num_children=0, + ) + + df = calculate_donation_effects(situation) + + # High earner should definitely have positive taxes + assert df["income_tax_after_donations"].min() > 0 + + # And substantial variation + tax_range = ( + df["income_tax_after_donations"].max() + - df["income_tax_after_donations"].min() + ) + + print(f"\nSingle high earner tax range: ${tax_range:,.0f}") + assert tax_range > 30000, f"Expected >$30k variation, got ${tax_range:,.0f}" diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..5c6f535 --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,64 @@ +"""Tests for donation simulation functions.""" + +import pytest +from policyengine_us import Simulation +from givecalc import CURRENT_YEAR, create_situation, create_donation_simulation + + +def test_create_donation_simulation(): + """Test creating a donation simulation.""" + + situation = create_situation(employment_income=100000) + simulation = create_donation_simulation(situation, donation_amount=5000) + + assert isinstance(simulation, Simulation) + + +def test_create_donation_simulation_removes_axes(): + """Test that create_donation_simulation removes axes.""" + situation = create_situation(employment_income=100000) + assert "axes" in situation # Original has axes + + simulation = create_donation_simulation(situation, donation_amount=5000) + + # Simulation should not have axes + # We can't directly check this, but we can verify it returns single values + net_income = simulation.calculate( + "household_net_income", CURRENT_YEAR, map_to="household" + ) + assert len(net_income) == 1 # Single value, not array + + +def test_create_donation_simulation_applies_donation(): + """Test that the donation amount is applied.""" + situation = create_situation(employment_income=100000) + donation_amount = 10000 + + simulation = create_donation_simulation(situation, donation_amount) + + # The donation should be reflected in the calculation + # We can verify by checking that donations affect the calculation + sim_no_donation = create_donation_simulation(situation, 0) + sim_with_donation = create_donation_simulation(situation, donation_amount) + + net_income_no_donation = sim_no_donation.calculate( + "household_net_income", CURRENT_YEAR, map_to="household" + )[0] + net_income_with_donation = sim_with_donation.calculate( + "household_net_income", CURRENT_YEAR, map_to="household" + )[0] + + # Net income should differ when donation is applied + # Note: May not always decrease due to benefit interactions + assert net_income_with_donation != net_income_no_donation + + +def test_create_donation_simulation_does_not_modify_original(): + """Test that create_donation_simulation doesn't modify original situation.""" + situation = create_situation(employment_income=100000) + original_axes = situation.get("axes") + + simulation = create_donation_simulation(situation, donation_amount=5000) + + # Original situation should still have axes + assert situation.get("axes") == original_axes diff --git a/tests/test_situation.py b/tests/test_situation.py new file mode 100644 index 0000000..8916c9a --- /dev/null +++ b/tests/test_situation.py @@ -0,0 +1,105 @@ +"""Tests for situation creation.""" + +import pytest +from givecalc import CURRENT_YEAR, DEFAULT_AGE, create_situation + + +def test_create_situation_single(): + """Test creating a situation for a single person.""" + + situation = create_situation(employment_income=100000) + + # Check people + assert "you" in situation["people"] + assert situation["people"]["you"]["age"][CURRENT_YEAR] == DEFAULT_AGE + assert ( + situation["people"]["you"]["employment_income"][CURRENT_YEAR] == 100000 + ) + + # Check entities + assert "your family" in situation["families"] + assert "you" in situation["families"]["your family"]["members"] + + # Check axes + assert len(situation["axes"]) == 1 + assert situation["axes"][0][0]["name"] == "charitable_cash_donations" + assert situation["axes"][0][0]["min"] == 0 + assert situation["axes"][0][0]["max"] == 100000 + + +def test_create_situation_married(): + """Test creating a situation for a married couple.""" + situation = create_situation( + employment_income=150000, is_married=True + ) + + # Check spouse exists + assert "your spouse" in situation["people"] + assert situation["people"]["your spouse"]["age"][CURRENT_YEAR] == DEFAULT_AGE + + # Check spouse in all entities + members = situation["families"]["your family"]["members"] + assert "you" in members + assert "your spouse" in members + + +def test_create_situation_with_children(): + """Test creating a situation with children.""" + situation = create_situation(employment_income=100000, num_children=2) + + # Check children exist + assert "child_0" in situation["people"] + assert "child_1" in situation["people"] + assert situation["people"]["child_0"]["age"][CURRENT_YEAR] == 10 + assert situation["people"]["child_1"]["age"][CURRENT_YEAR] == 10 + + # Check children in family + members = situation["families"]["your family"]["members"] + assert "child_0" in members + assert "child_1" in members + + +def test_create_situation_with_state(): + """Test creating a situation with state specification.""" + situation = create_situation(employment_income=100000, state_code="NY") + + assert ( + situation["households"]["your household"]["state_name"][CURRENT_YEAR] + == "NY" + ) + + +def test_create_situation_with_nyc(): + """Test creating a situation with NYC specification.""" + situation = create_situation( + employment_income=100000, state_code="NY", in_nyc=True + ) + + assert ( + situation["households"]["your household"]["in_nyc"][CURRENT_YEAR] + is True + ) + + +def test_create_situation_with_deductions(): + """Test creating a situation with itemized deductions.""" + situation = create_situation( + employment_income=100000, + mortgage_interest=10000, + real_estate_taxes=5000, + medical_out_of_pocket_expenses=3000, + casualty_loss=2000, + ) + + person = situation["people"]["you"] + assert person["mortgage_interest"][CURRENT_YEAR] == 10000 + assert person["real_estate_taxes"][CURRENT_YEAR] == 5000 + assert person["medical_out_of_pocket_expenses"][CURRENT_YEAR] == 3000 + assert person["casualty_loss"][CURRENT_YEAR] == 2000 + + +def test_create_situation_axes_count(): + """Test that axes have correct count.""" + situation = create_situation(employment_income=100000) + + assert situation["axes"][0][0]["count"] == 1001 diff --git a/tests/test_tax.py b/tests/test_tax.py new file mode 100644 index 0000000..ebd7e11 --- /dev/null +++ b/tests/test_tax.py @@ -0,0 +1,121 @@ +"""Tests for tax calculation functions.""" + +import pytest +import pandas as pd +import numpy as np +from givecalc import ( + CURRENT_YEAR, + create_situation, + calculate_donation_metrics, + calculate_donation_effects, + create_donation_dataframe, +) + + +def test_calculate_donation_metrics_returns_dict(): + """Test that calculate_donation_metrics returns a dictionary with expected keys.""" + + situation = create_situation(employment_income=100000) + metrics = calculate_donation_metrics(situation, donation_amount=5000) + + assert isinstance(metrics, dict) + assert "baseline_income_tax" in metrics + assert "baseline_net_income" in metrics + + +def test_calculate_donation_metrics_zero_donation(): + """Test metrics with zero donation.""" + situation = create_situation(employment_income=100000) + metrics = calculate_donation_metrics(situation, donation_amount=0) + + # Should have positive income tax + assert metrics["baseline_income_tax"] > 0 + # Net income should be less than gross income + assert metrics["baseline_net_income"] < 100000 + + +def test_calculate_donation_metrics_with_donation(): + """Test that donations affect tax calculation correctly.""" + situation = create_situation(employment_income=100000) + metrics_no_donation = calculate_donation_metrics(situation, donation_amount=0) + metrics_with_donation = calculate_donation_metrics( + situation, donation_amount=10000 + ) + + # Metrics should differ when donation amount changes + # Note: Net income may not always decrease due to benefit interactions + assert ( + metrics_with_donation["baseline_net_income"][0] + != metrics_no_donation["baseline_net_income"][0] + ) + + +def test_calculate_donation_effects_returns_dataframe(): + """Test that calculate_donation_effects returns a DataFrame.""" + situation = create_situation(employment_income=100000) + df = calculate_donation_effects(situation) + + assert isinstance(df, pd.DataFrame) + + +def test_calculate_donation_effects_columns(): + """Test that the DataFrame has expected columns.""" + situation = create_situation(employment_income=100000) + df = calculate_donation_effects(situation) + + expected_columns = [ + "charitable_cash_donations", + "income_tax", + "income_tax_after_donations", + "marginal_savings", + ] + for col in expected_columns: + assert col in df.columns + + +def test_calculate_donation_effects_shape(): + """Test that the DataFrame has 1001 rows (from axes).""" + situation = create_situation(employment_income=100000) + df = calculate_donation_effects(situation) + + assert len(df) == 1001 # 1001 points from axes + + +def test_calculate_donation_effects_donation_range(): + """Test that donations range from 0 to income.""" + income = 100000 + situation = create_situation(employment_income=income) + df = calculate_donation_effects(situation) + + assert df["charitable_cash_donations"].min() == 0 + assert df["charitable_cash_donations"].max() == income + + +def test_calculate_donation_effects_marginal_savings(): + """Test that marginal savings are between 0 and 1.""" + situation = create_situation(employment_income=100000) + df = calculate_donation_effects(situation) + + # Remove NaN values (can occur at boundaries) + marginal_savings = df["marginal_savings"].dropna() + + # Marginal savings should generally be between 0 and 1 (0% to 100%) + # Though in edge cases they could be negative or >1 + assert marginal_savings.min() >= -0.5 # Allow some flexibility + assert marginal_savings.max() <= 1.5 # Allow some flexibility + + +def test_create_donation_dataframe(): + """Test the create_donation_dataframe function.""" + donations = np.linspace(0, 100000, 1001) + income_tax = np.linspace(20000, 15000, 1001) # Tax decreases with donation + + df = create_donation_dataframe( + donations, income_tax, "charitable_cash_donations" + ) + + assert isinstance(df, pd.DataFrame) + assert len(df) == 1001 + assert "charitable_cash_donations" in df.columns + assert "income_tax" in df.columns + assert "marginal_savings" in df.columns diff --git a/ui/basic.py b/ui/basic.py index 8758cb6..0c054b5 100644 --- a/ui/basic.py +++ b/ui/basic.py @@ -1,6 +1,6 @@ # ui_basic.py import streamlit as st -from constants import PE_VERSION, CURRENT_YEAR, TEAL_ACCENT +from givecalc.constants import PE_VERSION, CURRENT_YEAR, TEAL_ACCENT def render_intro(): @@ -33,7 +33,10 @@ def render_notes(): def render_state_selector(states): - state = st.selectbox("What state do you live in?", options=states) + state = st.selectbox( + "What state do you live in?", + options=states, + ) in_nyc = False if state == "NY": in_nyc = st.checkbox( diff --git a/ui/donations.py b/ui/donations.py index e19d1c6..cc5d66d 100644 --- a/ui/donations.py +++ b/ui/donations.py @@ -1,6 +1,6 @@ # ui_donations.py import streamlit as st -from constants import TEAL_ACCENT +from givecalc.constants import TEAL_ACCENT def render_initial_donation(income): diff --git a/ui/target_donation.py b/ui/target_donation.py index 878bc9a..ad404c7 100644 --- a/ui/target_donation.py +++ b/ui/target_donation.py @@ -1,9 +1,11 @@ import streamlit as st -from visualization import create_net_income_plot -from calculations.net_income import add_net_income_columns -from calculations.donations import calculate_target_donation -from calculations.tax import calculate_donation_metrics -from constants import TEAL_ACCENT +from givecalc import ( + add_net_income_columns, + calculate_target_donation, + calculate_donation_metrics, + TEAL_ACCENT, +) +from ui.visualization import create_net_income_plot def render_target_donation_section( diff --git a/tax_info.py b/ui/tax_info.py similarity index 100% rename from tax_info.py rename to ui/tax_info.py diff --git a/ui/tax_results.py b/ui/tax_results.py index 879611c..b1751c7 100644 --- a/ui/tax_results.py +++ b/ui/tax_results.py @@ -1,6 +1,6 @@ import streamlit as st -from visualization import create_tax_plot, create_marginal_savings_plot -from constants import TEAL_ACCENT, MARGIN +from givecalc import TEAL_ACCENT, MARGIN +from ui.visualization import create_tax_plot, create_marginal_savings_plot def render_tax_results( diff --git a/visualization.py b/ui/visualization.py similarity index 94% rename from visualization.py rename to ui/visualization.py index dac1d8b..3aab853 100644 --- a/visualization.py +++ b/ui/visualization.py @@ -2,7 +2,7 @@ import plotly.graph_objects as go # from policyengine_core.charts import format_fig -from constants import TEAL_ACCENT +from givecalc.constants import TEAL_ACCENT def create_tax_plot( @@ -44,14 +44,15 @@ def create_tax_plot( ) ) + # Set y-axis to start at 0 for taxes + y_max = df[y_col].max() + y_padding = y_max * 0.1 # 10% padding on top + fig.update_layout( xaxis_tickformat="$,", yaxis_tickformat="$,", xaxis_range=[0, income], - yaxis_range=[ - min(min(df[y_col]) * 1.05, 0), - max(max(df[y_col]) * 1.05, 0), - ], + yaxis_range=[0, y_max + y_padding], xaxis=dict(zeroline=True, zerolinewidth=1, zerolinecolor="gray"), yaxis=dict(zeroline=True, zerolinewidth=1, zerolinecolor="gray"), showlegend=False, @@ -106,11 +107,15 @@ def create_marginal_savings_plot( ) ) + # Get actual max for better range + max_marginal = df["marginal_savings"].max() + y_max = min(max_marginal * 1.1, 1.0) # Cap at 100% but add 10% padding + fig.update_layout( xaxis_tickformat="$,", yaxis_tickformat=".0%", xaxis_range=[0, max(df[donation_column])], - yaxis_range=[0, 1], + yaxis_range=[0, y_max], xaxis=dict(zeroline=True, zerolinewidth=1, zerolinecolor="gray"), yaxis=dict(zeroline=True, zerolinewidth=1, zerolinecolor="gray"), showlegend=False,