Integration Examples

Note: Some sections show Svelte; invoke() usage is identical from React (@tauri-apps/api/core). See frontend-react.

Python Sidecar Integration

1. Python Command Interface

Create a CLI interface in the Python package:

# external_package/module/cli.py
import sys
import json
from pathlib import Path
from module.services import compute_positions, build_chart_instance
from module.workspace import load_workspace, add_or_update_chart, save_workspace_modular
from module.models import ChartMode, EngineType

def cmd_compute_positions(args):
    """Compute positions for a chart.

    Args:
        args: dict with keys: chart_id, workspace_path, datetime, location, engine, ephemeris_path
    """
    workspace_path = args.get('workspace_path')
    chart_id = args.get('chart_id')

    # Load workspace
    ws = load_workspace(workspace_path)

    # Find chart
    chart = None
    for c in ws.charts:
        if getattr(c, 'id', '') == chart_id:
            chart = c
            break

    if not chart:
        return {'error': f'Chart {chart_id} not found'}

    # Compute positions
    from module.services import compute_positions_for_chart
    positions = compute_positions_for_chart(chart, ws=ws)

    return {'positions': positions, 'chart_id': chart_id}

def cmd_compute_transit_series(args):
    """Compute transit series for a time range.

    Args:
        args: dict with keys:
            - source_chart_id: Base chart ID
            - workspace_path: Path to workspace
            - start_datetime: Start time (ISO format, supports microseconds)
            - end_datetime: End time (ISO format, supports microseconds)
            - time_step: Step size (e.g., '1 second', '1 minute', '1 hour', '1 day')
            - objects: List of objects to compute
            - aspects: List of aspects to compute
            - engine: Engine type
            - ephemeris_path: Optional ephemeris file
            - include_physical: Whether to include all physical properties (JPL only)
              (distance, declination, RA are always included for JPL)
            - include_topocentric: Whether to include altitude/azimuth (JPL with location)
            - include_extended: Whether to include magnitude/phase/elongation (JPL for planets)
    """
    from datetime import datetime, timedelta
    from dateutil.parser import parse
    import pandas as pd

    workspace_path = args.get('workspace_path')
    source_chart_id = args.get('source_chart_id')
    start_str = args.get('start_datetime')
    end_str = args.get('end_datetime')
    time_step = args.get('time_step', '1 hour')  # Default to 1 hour
    objects = args.get('objects', [])
    aspects = args.get('aspects', [])
    include_physical = args.get('include_physical', True)  # Default: include all physical data
    include_topocentric = args.get('include_topocentric', True)  # Default: include alt/az if location available
    include_extended = args.get('include_extended', False)  # Default: don't include magnitude/phase (can be heavy)

    # Load workspace and source chart
    ws = load_workspace(workspace_path)
    source_chart = None
    for c in ws.charts:
        if getattr(c, 'id', '') == source_chart_id:
            source_chart = c
            break

    if not source_chart:
        return {'error': f'Source chart {source_chart_id} not found'}

    # Parse time range (supports microseconds for high precision)
    start_dt = parse(start_str)
    end_dt = parse(end_str)

    # Generate time points with high precision support
    time_points = []
    current = start_dt

    # Parse time_step with support for seconds, minutes, hours, days
    def parse_time_step(step_str):
        """Parse time step string like '1 second', '30 seconds', '1 minute', etc."""
        parts = step_str.lower().strip().split()
        if len(parts) < 2:
            return timedelta(hours=1)  # Default to 1 hour

        value = int(parts[0])
        unit = parts[1]

        if 'second' in unit:
            return timedelta(seconds=value)
        elif 'minute' in unit:
            return timedelta(minutes=value)
        elif 'hour' in unit:
            return timedelta(hours=value)
        elif 'day' in unit:
            return timedelta(days=value)
        else:
            return timedelta(hours=1)  # Default

    step_delta = parse_time_step(time_step)

    while current <= end_dt:
        time_points.append(current)
        current += step_delta

    # Compute positions for each timepoint
    results = []
    for tp in time_points:
        # Create temporary transit chart
        transit_chart = build_chart_instance(
            name=f"transit_{source_chart_id}",
            dt_str=tp.isoformat(),
            loc_text=getattr(getattr(source_chart, 'subject', None), 'location', {}).get('name', ''),
            mode=ChartMode.EVENT,
            ws=ws
        )

        # Compute positions (JPL always includes distance, declination, RA)
        transit_positions = compute_positions_for_chart(transit_chart, ws=ws)
        source_positions = compute_positions_for_chart(source_chart, ws=ws)

        # For JPL engine, compute all physical properties
        transit_physical = {}
        source_physical = {}

        if getattr(transit_chart.config, 'engine', None) == EngineType.JPL:
            # Extended computation: distance, declination, RA are always computed
            # Optionally compute: altitude, azimuth, magnitude, phase, elongation
            # This requires extending compute_positions to return extended dict
            # Structure: {
            #   'longitude': float,
            #   'distance': float,  # Always present for JPL
            #   'declination': float,  # Always present for JPL
            #   'right_ascension': float,  # Always present for JPL
            #   'altitude': float,  # If include_topocentric
            #   'azimuth': float,  # If include_topocentric
            #   'apparent_magnitude': float,  # If include_extended
            #   'phase_angle': float,  # If include_extended
            #   'elongation': float,  # If include_extended
            # }
            pass

        # Compute aspects (simplified - you'd use your aspect computation logic)
        aspects_found = []
        for obj1 in objects:
            if obj1 not in source_positions:
                continue
            for obj2 in objects:
                if obj2 not in transit_positions:
                    continue
                # Compute angle between objects
                angle = abs(transit_positions[obj2] - source_positions[obj1])
                if angle > 180:
                    angle = 360 - angle

                # Check for aspects
                for aspect_type, aspect_angle in [('conjunction', 0), ('opposition', 180), ('trine', 120), ('square', 90)]:
                    orb = abs(angle - aspect_angle)
                    if orb < 8.0:  # 8 degree orb
                        aspects_found.append({
                            'source': obj1,
                            'target': obj2,
                            'type': aspect_type,
                            'angle': angle,
                            'orb': orb
                        })

        result_entry = {
            'datetime': tp.isoformat(),  # ISO format with microseconds
            'positions': transit_positions,  # Always includes longitude
            'aspects': aspects_found
        }

        # For JPL engine, physical properties are always included in positions dict
        # Structure: positions[object_id] = {
        #   'longitude': float,
        #   'distance': float,  # Always present for JPL
        #   'declination': float,  # Always present for JPL
        #   'right_ascension': float,  # Always present for JPL
        #   'altitude': float,  # If include_topocentric and location available
        #   'azimuth': float,  # If include_topocentric and location available
        #   'apparent_magnitude': float,  # If include_extended
        #   'phase_angle': float,  # If include_extended
        #   'elongation': float,  # If include_extended
        # }

        results.append(result_entry)

    return {
        'source_chart_id': source_chart_id,
        'time_range': {'start': start_str, 'end': end_str},
        'results': results
    }

def main():
    """Main CLI entry point."""
    if len(sys.argv) < 2:
        print(json.dumps({'error': 'No command specified'}), file=sys.stderr)
        sys.exit(1)

    command = sys.argv[1]
    args_json = sys.argv[2] if len(sys.argv) > 2 else '{}'
    args = json.loads(args_json)

    try:
        if command == 'compute_positions':
            result = cmd_compute_positions(args)
        elif command == 'compute_transit_series':
            result = cmd_compute_transit_series(args)
        else:
            result = {'error': f'Unknown command: {command}'}

        print(json.dumps(result))
    except Exception as e:
        print(json.dumps({'error': str(e), 'type': type(e).__name__}), file=sys.stderr)
        sys.exit(1)

if __name__ == '__main__':
    main()

2. Rust Tauri Command

// src-tauri/src/commands/compute.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
use std::path::PathBuf;
use tauri::State;

#[derive(Debug, Serialize, Deserialize)]
struct ComputePositionsArgs {
    chart_id: String,
    workspace_path: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct ComputeTransitSeriesArgs {
    source_chart_id: String,
    workspace_path: String,
    start_datetime: String,
    end_datetime: String,
    time_step: Option<String>,
    objects: Option<Vec<String>>,
    aspects: Option<Vec<String>>,
}

#[tauri::command]
pub async fn compute_chart_positions(
    args: ComputePositionsArgs,
) -> Result<HashMap<String, f64>, String> {
    // Get Python executable path
    let python_exe = find_python_executable()?;

    // Get module path
    let module_path = get_module_path()?;

    // Build command
    let args_json = serde_json::to_string(&args)
        .map_err(|e| format!("Failed to serialize args: {}", e))?;

    let output = Command::new(python_exe)
        .arg("-m")
        .arg("module.cli")
        .arg("compute_positions")
        .arg(&args_json)
        .current_dir(&module_path)
        .output()
        .map_err(|e| format!("Failed to execute Python: {}", e))?;

    if !output.status.success() {
        let error = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Python error: {}", error));
    }

    let result: serde_json::Value = serde_json::from_slice(&output.stdout)
        .map_err(|e| format!("Failed to parse Python output: {}", e))?;

    if let Some(error) = result.get("error") {
        return Err(error.as_str().unwrap().to_string());
    }

    let positions: HashMap<String, f64> = serde_json::from_value(
        result.get("positions").ok_or("No positions in response")?.clone()
    ).map_err(|e| format!("Failed to parse positions: {}", e))?;

    Ok(positions)
}

#[tauri::command]
pub async fn compute_transit_series(
    args: ComputeTransitSeriesArgs,
) -> Result<String, String> {
    // Similar implementation for transit series
    // Returns relation_id for tracking
    todo!()
}

fn find_python_executable() -> Result<PathBuf, String> {
    // Try common Python paths
    let candidates = vec!["python3", "python", "py"];

    for cmd in candidates {
        if Command::new(cmd)
            .arg("--version")
            .output()
            .is_ok()
        {
            return Ok(PathBuf::from(cmd));
        }
    }

    Err("Python executable not found".to_string())
}

fn get_module_path() -> Result<PathBuf, String> {
    // Get path to external_package/module
    // In production, this would be relative to the Tauri app
    let current_dir = std::env::current_dir()
        .map_err(|e| format!("Failed to get current directory: {}", e))?;

    Ok(current_dir.join("external_package"))
}

3. DuckDB Storage Integration

// src-tauri/src/storage/duckdb.rs
use duckdb::{Connection, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

pub struct DuckDBStorage {
    conn: Connection,
}

impl DuckDBStorage {
    pub fn new(db_path: &str) -> Result<Self> {
        let conn = Connection::open(db_path)?;

        // Initialize schema
        conn.execute_batch(
            r#"
            CREATE TABLE IF NOT EXISTS computed_positions (
                chart_id TEXT NOT NULL,
                datetime TIMESTAMP NOT NULL,
                object_id TEXT NOT NULL,
                longitude REAL NOT NULL,
                latitude REAL,
                distance REAL,
                speed REAL,
                retrograde BOOLEAN,
                engine TEXT,
                ephemeris_file TEXT,
                PRIMARY KEY (chart_id, datetime, object_id)
            );

            CREATE INDEX IF NOT EXISTS idx_positions_chart_datetime
                ON computed_positions(chart_id, datetime);
            "#,
        )?;

        Ok(Self { conn })
    }

    pub fn store_positions(
        &self,
        chart_id: &str,
        datetime: &str,
        positions: &HashMap<String, PositionData>,
        engine: &str,
    ) -> Result<()> {
        // PositionData is a struct containing all physical properties
        // For JPL: distance, declination, RA are always present
        // For other engines: only longitude may be present

        let mut stmt = self.conn.prepare(
            "INSERT OR REPLACE INTO computed_positions
             (chart_id, datetime, object_id, longitude, latitude,
              declination, right_ascension, distance,
              altitude, azimuth,
              apparent_magnitude, phase_angle, elongation, light_time,
              speed, retrograde,
              has_equatorial, has_topocentric, has_physical, engine)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
        )?;

        for (object_id, pos_data) in positions {
            let has_equatorial = pos_data.declination.is_some()
                              && pos_data.right_ascension.is_some()
                              && pos_data.distance.is_some();
            let has_topocentric = pos_data.altitude.is_some()
                               && pos_data.azimuth.is_some();
            let has_physical = pos_data.apparent_magnitude.is_some()
                           || pos_data.phase_angle.is_some()
                           || pos_data.elongation.is_some();

            stmt.execute(params![
                chart_id,
                datetime,
                object_id,
                pos_data.longitude,
                pos_data.latitude,
                pos_data.declination,
                pos_data.right_ascension,
                pos_data.distance,  // Always Some(f64) for JPL, None for others
                pos_data.altitude,
                pos_data.azimuth,
                pos_data.apparent_magnitude,
                pos_data.phase_angle,
                pos_data.elongation,
                pos_data.light_time,
                pos_data.speed,
                pos_data.retrograde,
                has_equatorial,
                has_topocentric,
                has_physical,
                engine
            ])?;
        }

        Ok(())
    }

    pub fn query_positions(
        &self,
        chart_id: &str,
        start: &str,
        end: &str,
    ) -> Result<Vec<PositionRow>> {
        let mut stmt = self.conn.prepare(
            "SELECT datetime, object_id, longitude
             FROM computed_positions
             WHERE chart_id = ? AND datetime >= ? AND datetime <= ?
             ORDER BY datetime, object_id"
        )?;

        let rows = stmt.query_map(params![chart_id, start, end], |row| {
            Ok(PositionRow {
                datetime: row.get(0)?,
                object_id: row.get(1)?,
                longitude: row.get(2)?,
            })
        })?;

        rows.collect()
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct PositionData {
    pub longitude: f64,
    pub latitude: Option<f64>,

    // Equatorial coordinates (always present for JPL)
    pub declination: Option<f64>,      // Always Some for JPL
    pub right_ascension: Option<f64>, // Always Some for JPL
    pub distance: Option<f64>,        // Always Some for JPL (NOT optional - always computed)

    // Topocentric coordinates (JPL with location)
    pub altitude: Option<f64>,
    pub azimuth: Option<f64>,

    // Physical properties (JPL for planets)
    pub apparent_magnitude: Option<f64>,
    pub phase_angle: Option<f64>,
    pub elongation: Option<f64>,
    pub light_time: Option<f64>,  // Light time in seconds

    // Motion properties
    pub speed: Option<f64>,
    pub retrograde: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct PositionRow {
    pub datetime: String,
    pub object_id: String,
    pub data: PositionData,
}

4. Svelte Frontend Integration

// src/lib/stores/computations.ts
import { writable } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';

export interface ComputationJob {
	job_id: string;
	relation_id: string;
	status: 'pending' | 'running' | 'completed' | 'failed';
	progress: number;
}

export const computationJobs = writable<ComputationJob[]>([]);

export async function computeChartPositions(
	chartId: string,
	workspacePath: string
): Promise<Record<string, number>> {
	const positions = await invoke<Record<string, number>>('compute_chart_positions', {
		args: {
			chart_id: chartId,
			workspace_path: workspacePath
		}
	});
	return positions;
}

export async function computeTransitSeries(
	sourceChartId: string,
	workspacePath: string,
	startDatetime: string,
	endDatetime: string,
	timeStep: string = '1 day'
): Promise<string> {
	const relationId = await invoke<string>('compute_transit_series', {
		args: {
			source_chart_id: sourceChartId,
			workspace_path: workspacePath,
			start_datetime: startDatetime,
			end_datetime: endDatetime,
			time_step: timeStep
		}
	});
	return relationId;
}
<!-- src/lib/components/TransitComputation.svelte -->
<script lang="ts">
    import { computeTransitSeries } from '$lib/stores/computations';
    import { workspace } from '$lib/stores/workspace';

    let sourceChartId = $state('');
    let startDate = $state('');
    let endDate = $state('');
    let computing = $state(false);
    let relationId = $state<string | null>(null);

    async function handleCompute() {
        if (!sourceChartId || !startDate || !endDate) return;

        computing = true;
        try {
            const ws = $workspace;
            if (!ws) throw new Error('No workspace loaded');

            relationId = await computeTransitSeries(
                sourceChartId,
                ws.path,
                startDate,
                endDate
            );
        } catch (error) {
            console.error('Computation failed:', error);
        } finally {
            computing = false;
        }
    }
</script>

<div class="p-4 space-y-4">
    <h3 class="text-lg font-semibold">Compute Transit Series</h3>

    <div class="space-y-2">
        <label class="block text-sm">Source Chart</label>
        <select bind:value={sourceChartId} class="w-full p-2 border rounded">
            <!-- Populate from workspace charts -->
        </select>
    </div>

    <div class="grid grid-cols-2 gap-2">
        <div>
            <label class="block text-sm">Start Date</label>
            <input type="datetime-local" bind:value={startDate} class="w-full p-2 border rounded" />
        </div>
        <div>
            <label class="block text-sm">End Date</label>
            <input type="datetime-local" bind:value={endDate} class="w-full p-2 border rounded" />
        </div>
    </div>

    <button
        onclick={handleCompute}
        disabled={computing}
        class="w-full p-2 bg-primary text-primary-foreground rounded disabled:opacity-50"
    >
        {computing ? 'Computing...' : 'Compute Transit Series'}
    </button>

    {#if relationId}
        <div class="p-2 bg-success/10 text-success rounded">
            Computation started. Relation ID: {relationId}
        </div>
    {/if}
</div>