diff --git a/examples/sensor_utils.ipynb b/examples/sensor_utils.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..582920f5e5d1fe9b9198397569fdc75cc32af720
--- /dev/null
+++ b/examples/sensor_utils.ipynb
@@ -0,0 +1,316 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Sensor Utils\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os\n",
+    "\n",
+    "from csmapi import csmapi\n",
+    "from knoten import csm, sensor_utils\n",
+    "\n",
+    "import ale\n",
+    "import json"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Create a usgscsm sensor model"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "/Users/astamile/opt/anaconda3/envs/knoten/lib/python3.9/site-packages/osgeo/gdal.py:312: FutureWarning: Neither gdal.UseExceptions() nor gdal.DontUseExceptions() has been explicitly called. In GDAL 4.0, exceptions will be enabled by default.\n",
+      "  warnings.warn(\n"
+     ]
+    }
+   ],
+   "source": [
+    "fileName = \"data/N1573082850_1.cub\"\n",
+    "\n",
+    "kernels = ale.util.generate_kernels_from_cube(fileName, expand=True)\n",
+    "isd_string = ale.loads(fileName, props={'kernels': kernels})\n",
+    "csm_isd = os.path.splitext(fileName)[0] + '.json'\n",
+    "\n",
+    "with open(csm_isd, 'w') as isd_file:\n",
+    "    isd_file.write(isd_string)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Run Sensor Utils with usgscsm sensor model and image point"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "camera = csm.create_csm(csm_isd)\n",
+    "image_pt = csmapi.ImageCoord(511.5, 511.5)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "38.87212509629893"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "phaseAngle = sensor_utils.phase_angle(image_pt, camera)\n",
+    "\n",
+    "phaseAngle"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "49.60309924896046"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "emissionAngle = sensor_utils.emission_angle(image_pt, camera)\n",
+    "\n",
+    "emissionAngle"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "2903512972.146144"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "slantDistance = sensor_utils.slant_distance(image_pt, camera)\n",
+    "\n",
+    "slantDistance"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "2943536048.858226"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "targetCenterDistance = sensor_utils.target_center_distance(image_pt, camera)\n",
+    "\n",
+    "targetCenterDistance"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "LatLon(lat=3.2229625890973583, lon=258.6197326526089)"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "subSpacecraftPoint = sensor_utils.sub_spacecraft_point(image_pt, camera)\n",
+    "\n",
+    "subSpacecraftPoint"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "59096282.02424558"
+      ]
+     },
+     "execution_count": 9,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "localRadius = sensor_utils.local_radius(image_pt, camera)\n",
+    "\n",
+    "localRadius"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(79.34815579474038, -2.7790780986459485)"
+      ]
+     },
+     "execution_count": 10,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "rightAscDec = sensor_utils.right_ascension_declination(image_pt, camera)\n",
+    "\n",
+    "rightAscDec"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "17397.960941876583"
+      ]
+     },
+     "execution_count": 11,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "lineResolution = sensor_utils.line_resolution(image_pt, camera)\n",
+    "\n",
+    "lineResolution"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "17397.933700407997"
+      ]
+     },
+     "execution_count": 12,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "sampleResolution = sensor_utils.sample_resolution(image_pt, camera)\n",
+    "\n",
+    "sampleResolution"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "17397.94732114229"
+      ]
+     },
+     "execution_count": 13,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "pixelResolution = sensor_utils.pixel_resolution(image_pt, camera)\n",
+    "\n",
+    "pixelResolution"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.18"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/knoten/bundle.py b/knoten/bundle.py
index d59250afe73e279038ce3ec60fb33562ef001632..9d1389c8b9ac497abf5d8805de7c3f96555667e4 100644
--- a/knoten/bundle.py
+++ b/knoten/bundle.py
@@ -212,6 +212,35 @@ def compute_ground_partials(sensor, ground_pt):
     partials = np.array(sensor.computeGroundPartials(csm_ground))
     return np.reshape(partials, (2, 3))
 
+def compute_image_partials(sensor, ground_pt):
+    """
+    Compute the partial derivatives of the ground point with respect to
+    the line and sample at a ground point.
+
+    These are not normally available from the CSM model, so we use
+    csm::RasterGM::computeGroundPartials to get the Jacobian of the ground to
+    image transformation. Then we use the pseudoinverse of that to get the
+    Jacobian of the image to ground transformation.
+
+    Parameters
+    ----------
+    sensor : CSM sensor
+             The CSM sensor model
+    ground_pt : array
+                The (x, y, z) ground point to compute the partial derivatives W.R.T.
+
+    Returns
+    -------
+     : array
+       The partial derivatives of the image to ground transformation
+    """
+    if isinstance(ground_pt, csmapi.EcefCoord):
+        ground_pt = [ground_pt.x, ground_pt.y, ground_pt.z]
+    ground_matrix = compute_ground_partials(sensor, ground_pt)
+    image_matrix = np.linalg.pinv(ground_matrix)
+
+    return image_matrix.flatten()
+
 def compute_coefficient_columns(network, sensors, parameters):
     """
     Compute the columns for different coefficients
diff --git a/knoten/csm.py b/knoten/csm.py
index 9912283d00736e9d645dcc9e2ef22a37d0a1ccb9..0a569d30556e7eecc699121ca82751b1502600db 100644
--- a/knoten/csm.py
+++ b/knoten/csm.py
@@ -9,6 +9,7 @@ import numpy as np
 import requests
 import scipy.stats
 from functools import singledispatch
+import spiceypy as spice
 
 from knoten.surface import EllipsoidDem
 
@@ -41,6 +42,28 @@ def get_radii(camera):
     semi_minor = ellipsoid.getSemiMinorRadius()
     return semi_major, semi_minor
 
+def get_surface_normal(sensor, ground_pt):
+    """
+    Given a sensor model and ground point, calculate the surface normal.
+
+    Parameters
+    ----------
+    sensor : object
+             A CSM compliant sensor model object
+
+    ground_pt: tuple
+               The ground point as an (x, y, z) tuple
+
+    Returns
+    -------
+     : tuple
+       in the form (x, y, z)
+    """
+    semi_major, semi_minor = get_radii(sensor)
+
+    normal = spice.surfnm(semi_major, semi_major, semi_minor, np.array([ground_pt.x, ground_pt.y, ground_pt.z]))
+    return utils.Point(normal[0], normal[1], normal[2])
+
 def create_camera(label, url='http://pfeffer.wr.usgs.gov/api/1.0/pds/'):
     """
     Given an ALE supported label, create a CSM compliant ISD file. This func
@@ -73,6 +96,35 @@ def create_camera(label, url='http://pfeffer.wr.usgs.gov/api/1.0/pds/'):
     if plugin.canModelBeConstructedFromISD(isd, model_name):
         model = plugin.constructModelFromISD(isd, model_name)
         return model
+    
+def get_state(sensor, image_pt):
+    """
+    Get the state of the sensor model at a given image point.
+
+    Parameters
+    ----------
+    sensor : object
+             A CSM compliant sensor model object
+
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    Returns
+    -------
+    : dict
+        Dictionary containing lookVec, sensorPos, sensorTime, and imagePoint
+    """
+    sensor_time = sensor.getImageTime(image_pt)
+    locus = sensor.imageToRemoteImagingLocus(image_pt)
+    sensor_position = sensor.getSensorPosition(image_pt)
+
+    sensor_state = {
+        "lookVec": locus.direction,
+        "sensorPos": sensor_position,
+        "sensorTime": sensor_time,
+        "imagePoint": image_pt
+    }
+    return sensor_state
 
 def _from_state(state, verbose):
     with open(state, 'r') as stream:
diff --git a/knoten/sensor_utils.py b/knoten/sensor_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..812fecf32804cbab0b408e8bb68821f4611119de
--- /dev/null
+++ b/knoten/sensor_utils.py
@@ -0,0 +1,296 @@
+from knoten import csm, utils, bundle
+from csmapi import csmapi
+import numpy as np
+
+def phase_angle(image_pt, sensor):
+    """
+    Computes and returns phase angle, in degrees for a given image point.
+   
+    Phase Angle: The angle between the vector from the intersection point to
+    the observer (usually the spacecraft) and the vector from the intersection
+    point to the illuminator (usually the sun).
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+            phase angle in degrees
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    sensor_state = csm.get_state(sensor, image_pt)
+    ground_pt = csm.generate_ground_point(0.0, image_pt, sensor)
+  
+    sunEcefVec = sensor.getIlluminationDirection(ground_pt)
+    illum_pos = utils.Point(ground_pt.x - sunEcefVec.x, ground_pt.y - sunEcefVec.y, ground_pt.z - sunEcefVec.z)
+
+    vec_a = utils.Point(sensor_state["sensorPos"].x - ground_pt.x, 
+                        sensor_state["sensorPos"].y - ground_pt.y, 
+                        sensor_state["sensorPos"].z - ground_pt.z)
+
+    vec_b = utils.Point(illum_pos.x - ground_pt.x, 
+                        illum_pos.y - ground_pt.y, 
+                        illum_pos.z - ground_pt.z)
+  
+    return np.rad2deg(utils.sep_angle(vec_a, vec_b))
+
+def emission_angle(image_pt, sensor):
+    """
+    Computes and returns emission angle, in degrees, for a given image point.
+
+    Emission Angle: The angle between the surface normal vector at the
+    intersection point and the vector from the intersection point to the
+    observer (usually the spacecraft). The emission angle varies from 0 degrees
+    when the observer is viewing the sub-spacecraft point (nadir viewing) to 90
+    degrees when the intercept is tangent to the surface of the target body.
+    Thus, higher values of emission angle indicate more oblique viewing of the
+    target.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+            emission angle in degrees
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    sensor_state = csm.get_state(sensor, image_pt)
+    ground_pt = csm.generate_ground_point(0.0, image_pt, sensor)
+
+    normal = csm.get_surface_normal(sensor, ground_pt)
+
+    sensor_diff = utils.Point(sensor_state["sensorPos"].x - ground_pt.x, 
+                              sensor_state["sensorPos"].y - ground_pt.y,
+                              sensor_state["sensorPos"].z - ground_pt.z)
+    
+    return np.rad2deg(utils.sep_angle(normal, sensor_diff))
+
+def slant_distance(image_pt, sensor):
+    """
+    Computes the slant distance from the sensor to the ground point.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray 
+        slant distance in meters
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    sensor_state = csm.get_state(sensor, image_pt)
+    ground_pt = csm.generate_ground_point(0.0, image_pt, sensor)
+
+    return utils.distance(sensor_state["sensorPos"], ground_pt)
+
+def target_center_distance(image_pt, sensor):
+    """
+    Calculates and returns the distance from the spacecraft to the target center.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray 
+        target center distance in meters
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    sensor_state = csm.get_state(sensor, image_pt)
+    return utils.distance(sensor_state["sensorPos"], utils.Point(0,0,0))
+
+def sub_spacecraft_point(image_pt, sensor):
+    """
+    Get the latitude and longitude of the sub-spacecraft point.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+        sub spacecraft point in degrees
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    sensor_state = csm.get_state(sensor, image_pt)
+    lat_lon_rad = utils.rect_to_spherical(sensor_state["sensorPos"])
+
+    return utils.radians_to_degrees(lat_lon_rad)
+
+def local_radius(image_pt, sensor):
+    """
+    Gets the local radius for a given image point.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+        local radius in meters
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    ground_pt = csm.generate_ground_point(0.0, image_pt, sensor)
+    return utils.magnitude(ground_pt)
+
+def right_ascension_declination(image_pt, sensor):
+    """
+    Computes the right ascension and declination for a given image point.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : tuple
+       in the form (ra, dec) in degrees
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    sensor_state = csm.get_state(sensor, image_pt)
+    spherical_pt = utils.rect_to_spherical(sensor_state["lookVec"])
+    
+    ra_dec = utils.radians_to_degrees(spherical_pt)
+
+    return (ra_dec.lon, ra_dec.lat)
+
+def line_resolution(image_pt, sensor):
+    """
+    Compute the line resolution in meters per pixel for the current set point.
+
+    CSM sensor models do not expose all of the necessary parameters to do the
+    same calculation as ISIS sensor models, so this uses a more time consuming but
+    more accurate method and thus is equivalent to the oblique line resolution.
+
+    For time dependent sensor models, this may also be the line-to-line resolution
+    and not the resolution within a line or framelet. This is determined by the
+    CSM model's ground computeGroundPartials method.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+        line resolution in meters/pixel
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    ground_pt = csm.generate_ground_point(0.0, image_pt, sensor)
+    image_partials = bundle.compute_image_partials(sensor, ground_pt)
+
+    return np.sqrt(image_partials[0] * image_partials[0] +
+                   image_partials[2] * image_partials[2] +
+                   image_partials[4] * image_partials[4])
+
+def sample_resolution(image_pt, sensor):
+    """
+    Compute the sample resolution in meters per pixel for the current set point.
+
+    CSM sensor models do not expose all of the necessary parameters to do the
+    same calculation as ISIS sensor models, so this uses a more time consuming but
+    more accurate method and thus is equivalent to the oblique sample resolution.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+        sample resolution in meters/pixel
+    """
+    if not isinstance(image_pt, csmapi.ImageCoord):
+        image_pt = csmapi.ImageCoord(*image_pt)
+
+    ground_pt = csm.generate_ground_point(0.0, image_pt, sensor)
+    image_partials = bundle.compute_image_partials(sensor, ground_pt)
+
+    return np.sqrt(image_partials[1] * image_partials[1] +
+                   image_partials[3] * image_partials[3] +
+                   image_partials[5] * image_partials[5])
+
+def pixel_resolution(image_pt, sensor):
+    """
+    Returns the pixel resolution at the current position in meters/pixel.
+
+    Parameters
+    ----------
+    image_pt : tuple
+               Pair of x, y (sample, line) coordinates in pixel space
+
+    sensor : object
+             A CSM compliant sensor model object
+
+    Returns
+    -------
+     : np.ndarray
+        pixel resolution in meters/pixel
+    """
+    line_res = line_resolution(image_pt, sensor)
+    samp_res = sample_resolution(image_pt, sensor)
+    if (line_res < 0.0):
+        return None
+    if (samp_res < 0.0):
+        return None
+    return (line_res + samp_res) / 2.0
\ No newline at end of file
diff --git a/tests/test_bundle.py b/tests/test_bundle.py
index 6b95d67fe5c4be7cd01e8131dec88d5d0f4f1e95..eac4c17ac891b406f3f1d08d1c0635a42aef2691 100644
--- a/tests/test_bundle.py
+++ b/tests/test_bundle.py
@@ -121,6 +121,15 @@ def test_compute_ground_partials():
     partials = bundle.compute_ground_partials(sensor, ground_pt)
     np.testing.assert_array_equal(partials, [[1, 2, 3], [4, 5, 6]])
 
+def test_compute_image_partials():
+    ground_pt = [9, 8, 10]
+    sensor = mock.MagicMock(spec=csmapi.RasterGM)
+    sensor.computeGroundPartials.return_value = (1, 2, 3, 4, 5, 6)
+    partials = bundle.compute_image_partials(sensor, ground_pt)
+    np.testing.assert_allclose(partials, np.array([-0.94444444, 0.44444444, 
+                                                   -0.11111111, 0.11111111, 
+                                                   0.72222222, -0.22222222]))
+
 def test_compute_jacobian(control_network, sensors):
     parameters = {sn: [mock.MagicMock()]*2 for sn in sensors}
     sensor_partials = [(i+1) * np.ones((2, 2)) for i in range(9)]
diff --git a/tests/test_csm.py b/tests/test_csm.py
index d10320788fca4bb8e7b6985b49eea95e02d6b534..ede5308ccf6b3a3763287a0f0e32847b74ac84dc 100644
--- a/tests/test_csm.py
+++ b/tests/test_csm.py
@@ -5,7 +5,7 @@ import pytest
 from plio.io.io_gdal import GeoDataset
 
 import csmapi
-from knoten import csm, surface
+from knoten import csm, surface, utils
 
 @pytest.fixture
 def mock_dem():
@@ -66,4 +66,31 @@ def test__compute_intersection_distance():
     pt1 = Point(0,0,0)
     pt2 = Point(1,1,1)
     dist = csm._compute_intersection_distance(pt1, pt2)
-    assert dist == 1
\ No newline at end of file
+    assert dist == 1
+
+
+def test_get_state(mock_sensor, pt):
+    Locus = namedtuple("Locus", 'point direction')
+
+    mock_sensor.getImageTime.return_value = 0.0
+    mock_sensor.imageToRemoteImagingLocus.return_value = Locus(utils.Point(0, 1, 2), utils.Point(0, 1, 2))
+    mock_sensor.getSensorPosition.return_value = utils.Point(2, 2, 2)
+
+    state = csm.get_state(mock_sensor, pt)
+
+    expected = {
+        "lookVec": utils.Point(0, 1, 2),
+        "sensorPos": utils.Point(2, 2, 2),
+        "sensorTime": 0.0,
+        "imagePoint": pt
+    }
+
+    assert state == expected
+
+
+@mock.patch.object(csm, 'get_radii', return_value=(10, 10))
+def test_get_surface_normal(mock_sensor):
+    ground_pt = utils.Point(1, 0, 0)
+    normal = csm.get_surface_normal(mock_sensor, ground_pt)
+
+    assert normal == (1.0, 0.0, 0.0)
\ No newline at end of file
diff --git a/tests/test_sensorutils.py b/tests/test_sensorutils.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7be63a93e4a8a05f167cfc3b95750d0c371586e
--- /dev/null
+++ b/tests/test_sensorutils.py
@@ -0,0 +1,103 @@
+from unittest import mock
+import pytest
+
+import numpy as np
+import csmapi
+from knoten import csm, sensor_utils, utils, bundle
+
+@pytest.fixture
+def mock_sensor():
+    mock_sensor = mock.MagicMock(spec=csmapi.RasterGM)
+    return mock_sensor
+
+@pytest.fixture
+def pt():
+    return csmapi.ImageCoord(0.0, 0.0)
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(100.0, 0.0, 0.0)})
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(0,0,0))
+def test_phase_angle(mock_sensor, pt):
+    mock_sensor.getIlluminationDirection.return_value = utils.Point(100.0, 100.0, 0.0)
+    phase_angle = sensor_utils.phase_angle(pt, mock_sensor)
+
+    np.testing.assert_array_equal(phase_angle, 135.0)
+
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(100.0, 0.0, 0.0)})
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(0,0,0))
+def test_emission_angle(mock_sensor, pt):
+    with mock.patch.object(csm, 'get_surface_normal', return_value=utils.Point(-1,0,0)) as mock_normal:
+        emission_angle = sensor_utils.emission_angle(pt, mock_sensor)
+
+        mock_normal.assert_called()
+
+    np.testing.assert_array_equal(emission_angle, 180.0)
+
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(-100.0, 0.0, 0.0)})
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(0,0,0))
+def test_slant_distance(mock_sensor, pt):
+    slant_distance = sensor_utils.slant_distance(pt, mock_sensor)
+
+    np.testing.assert_array_equal(slant_distance, 100.0)
+
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(-100.0, 0.0, 0.0)})
+def test_target_center_distance(mock_sensor, pt):
+    target_center_distance = sensor_utils.target_center_distance(pt, mock_sensor)
+
+    np.testing.assert_array_equal(target_center_distance, 100.0)
+
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(0.0, 0.0, 100.0)})
+def test_sub_spacecraft_point(mock_sensor, pt):
+    sub_spacecraft_point = sensor_utils.sub_spacecraft_point(pt, mock_sensor)
+
+    np.testing.assert_array_equal(sub_spacecraft_point, [90.0, 0.0])
+
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(100.0, 0.0, 0.0)})
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(10.0, 0.0, 0.0))
+def test_local_radius_intersection(mock_sensor, pt):
+    local_radius = sensor_utils.local_radius(pt, mock_sensor)
+
+    np.testing.assert_array_equal(local_radius, 10.0)
+
+
+@mock.patch.object(csm, 'get_state', return_value={'sensorPos': utils.Point(-1000.0, 0.0, 0.0)})
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(10.0, 0.0, 0.0))
+def test_local_radius_ground(mock_sensor, pt):
+    local_radius = sensor_utils.local_radius(pt, mock_sensor)
+
+    np.testing.assert_array_equal(local_radius, 10.0)
+
+
+@mock.patch.object(csm, 'get_state', return_value={'lookVec': utils.Point(-1.0, 0.0, 0.0)})
+def test_right_ascension_declination(mock_sensor, pt):
+    right_ascension_declination = sensor_utils.right_ascension_declination(pt, mock_sensor)
+
+    np.testing.assert_array_equal(right_ascension_declination, [180.0, 0.0])
+
+
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(0.0, 0.0, 0.0))
+@mock.patch.object(bundle, 'compute_image_partials', return_value=np.array([2, 1, 4, 4, 4, 8]))
+def test_line_resolution(mock_sensor, pt):
+    line_resolution = sensor_utils.line_resolution(pt, mock_sensor)
+
+    np.testing.assert_array_equal(line_resolution, 6.0)
+
+
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(0.0, 0.0, 0.0))
+@mock.patch.object(bundle, 'compute_image_partials', return_value=np.array([2, 1, 4, 4, 4, 8]))
+def test_sample_resolution(mock_sensor, pt):
+    sample_resolution = sensor_utils.sample_resolution(pt, mock_sensor)
+
+    np.testing.assert_array_equal(sample_resolution, 9.0)
+
+
+@mock.patch.object(csm, 'generate_ground_point', return_value=utils.Point(0.0, 0.0, 0.0))
+@mock.patch.object(bundle, 'compute_image_partials', return_value=np.array([2, 1, 4, 4, 4, 8]))
+def test_pixel_resolution(mock_sensor, pt):
+    pixel_resolution = sensor_utils.pixel_resolution(pt, mock_sensor)
+
+    np.testing.assert_array_equal(pixel_resolution, 7.5)
\ No newline at end of file