From 58553aa31ab521e2d8b85db7219390490254c17d Mon Sep 17 00:00:00 2001 From: mjrand Date: Tue, 9 Jun 2026 20:34:42 +0000 Subject: [PATCH 01/13] Added gen_sin_el_nod function --- socs/agents/acu/drivers.py | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index 90d535301..a53512a5f 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -999,6 +999,99 @@ def generate_type2_scan(az_endpoint1, az_endpoint2, az_speed, turnaround_method=turnaround_method) +def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, + el_freq=.15, + num_batches=None, + num_nods=None, + start_time=None, + wait_to_start=10., + step_time=1., + batch_size=500): + """Python generator to produce times, azimuth and elevation positions, + azimuth and elevation velocities, azimuth and elevation flags for + arbitrarily long sin el nods. + + Parameters: + az (float): azimuth position for the el nods. + el_endpoint1 (float): elevation endpoint for the scan start + el_endpoint2 (float): second elevation endpoint of the scan. For + constant az scans, this must be equal to el_endpoint1. + el_freq(float): frequency of the elevation nods in Hz. + num_batches (int or None): sets the number of batches for the + generator to create. Default value is None (interpreted as infinite + batches). + num_nods (int or None): if not None, limits the points + returned to the specified number of el nods (one nod is one full sine wave). + start_time (float or None): a ctime at which to start the scan. + Default is None, which is interpreted as starting now + + wait_to_start. + wait_to_start (float): number of seconds to wait between + start_time and when the scan actually starts. Default is 10 seconds. + step_time (float): time between points on the constant-velocity + parts of the motion. Default value is 1.0 seconds. Minimum value is + 0.05 seconds. + batch_size (int): number of values to produce in each iteration. + Default is 500. Batch size is reset to the length of one leg of the + motion if num_batches is not None. + + Yields: + points (list): a list of TrackPoint objects. Raises + StopIteration once exit condition, if defined, is met. + + """ + # Get el throw + el_throw = abs(el_endpoint2 - el_endpoint1) / 2 + el_cent = (el_endpoint1 + el_endpoint2) / 2. + + if start_time is None: + t0 = time.time() + wait_to_start + else: + t0 = start_time + + t = 0 + el = el_endpoint1 + if step_time < 0.05: + raise ValueError('Time step size too small, must be at least ' + '0.05 seconds') + + if num_batches is None: + stop_iter = float('inf') + else: + stop_iter = num_batches + batch_size = int(np.ceil(1 / (el_freq * step_time))) + + def check_completed_nods(): + return num_nods is None or t * el_freq > num_nods + + def get_el(_t): + return (el_cent - el_throw * np.cos(_t * el_freq * 2 * np.pi), + el_throw * el_freq * 2 * np.pi * np.sin(_t * el_freq * 2 * np.pi)) + + i = 0 + while i < stop_iter and check_completed_nods(): + i += 1 + point_block = [] + for j in range(batch_size): + el, el_vel = get_el(t + t0) + point_block.append(TrackPoint( + timestamp=t + t0, + az=az, el=el, az_vel=0, el_vel=el_vel, + az_flag=0, el_flag=1, + group_flag=0)) + + t += step_time + + if not check_completed_nods(): + # Kill the velocity on the last point and exit -- this + # was recommended at LAT FAT for smoothly stopping the + # motion at end of program. + point_block[-1].az_vel = 0 + point_block[-1].el_vel = 0 + break + + yield point_block + + def plan_scan(az_end1, az_end2, el, v_az=1, a_az=1, az_start=None, scan_type=1): """Determine some important parameters for running a ProgramTrack From 7a7ebc1e9eb11a11f9ffadbd24beb0030fb8ace7 Mon Sep 17 00:00:00 2001 From: mjrand Date: Tue, 9 Jun 2026 21:24:43 +0000 Subject: [PATCH 02/13] Added sin_el_nod scan type to generate_scan in ACU agent --- socs/agents/acu/agent.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index cc7deaeb4..2e3cea5fd 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2107,7 +2107,7 @@ def line_batcher(ff_scan, t_shift=0., n=10): choices=['end', 'mid', 'az_endpoint1', 'az_endpoint2', 'mid_inc', 'mid_dec']) @ocs_agent.param('az_drift', type=float, default=None) - @ocs_agent.param('scan_type', default=1, choices=[1, 2, 3]) + @ocs_agent.param('scan_type', default=1, choices=[1, 2, 3, 'sin_el_nod']) @ocs_agent.param('az_vel_ref', type=float, default=None) @ocs_agent.param('turnaround_method', default=None, choices=[None, 'standard', 'standard_gen', @@ -2181,6 +2181,7 @@ def generate_scan(self, session, params): Type 1 is a constant elevation scan. Type 2 includes a variation in az speed that scales as sin(az). Type 3 is a Type 2 with an sinusoidal el nod. + 'sin_el_nod' is a sinusoidal el nod with no az movement. az_vel_ref (float or None): azimuth to center the velocity profile at. If None then the average of the endpoints is used. turnaround_method (str): The method used for generating turnaround. @@ -2244,6 +2245,10 @@ def generate_scan(self, session, params): if el_mode is None: el_mode = self.scan_params['el_mode'] # ... which may also be None. + # Check our az endpoints for sin_el_nods. They should be the same! + if params['scan_type'] == 'sin_el_nod' and az_endpoint1 != az_endpoint2: + raise ValueError("Cannot run type 4 scans with different az endpoints!") + # Check if the turnaround method is usable for the called scan type. # This should never happen with the above turnaround_method setting. if turnaround_method == "standard" and params['scan_type'] != 1: @@ -2295,6 +2300,8 @@ def generate_scan(self, session, params): az_cent = az_vel_ref - 90 az_edge = np.max(np.abs((az_endpoint1 - az_cent, az_endpoint2 - az_cent))) az_edge_speed = az_speed / np.sin(az_edge) + if params['scan_type'] == 'sin_el_nod': + az_edge_speed = 0 plan = sh.plan_scan(az_endpoint1, az_endpoint2, el=el_endpoint1, v_az=az_edge_speed, a_az=az_accel, @@ -2408,8 +2415,18 @@ def generate_scan(self, session, params): az_vel_ref=az_vel_ref, az_first_pos=plan['init_az'], **scan_params) + + elif params['scan_type'] == 'sin_el_nod': + free_form = True + track_axes = ['el'] + g = sh.generate_sin_el_nod(az=az_endpoint1, + el_endpoint1=el_endpoint1, + el_endpoint2=el_endpoint2, + el_freq=el_freq, + **scan_params) + else: - raise ValueError("Scan type must be 1, 2, or 3") + raise ValueError("Scan type must be 1, 2, 3, or 'sin_el_nod'") scan_params_bundle = {'session_id': session.session_id, 'schema': 1, @@ -2437,7 +2454,7 @@ def generate_scan(self, session, params): ret_val = (yield self._run_track( session=session, point_gen=g, step_time=step_time, stop_accel=az_accel, track_axes=track_axes, point_batch_count=point_batch_count, - free_form=free_form, unabort_failure=(params['scan_type'] in [2, 3]))) + free_form=free_form, unabort_failure=(params['scan_type'] in [2, 3, 'sin_el_nod']))) self.agent.publish_to_feed('scan_params', {'timestamp': time.time(), From 2d88c5d042eb4dad68f5478a17039af449e01072 Mon Sep 17 00:00:00 2001 From: mjrand Date: Wed, 10 Jun 2026 23:50:49 +0000 Subject: [PATCH 03/13] Made generate_sin_el_nod its own agent process --- socs/agents/acu/agent.py | 218 +++++++++++++++++++++++++++++++++---- socs/agents/acu/drivers.py | 28 ++--- 2 files changed, 210 insertions(+), 36 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 2e3cea5fd..1ad69fb95 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -362,6 +362,11 @@ def __init__(self, agent, acu_config='guess', self._simple_process_stop, blocking=False, startup=False) + agent.register_process('generate_sin_el_nod', + self.generate_sin_el_nod, + self._simple_process_stop, + blocking=False, + startup=False) agent.register_process('idle_reset', self.idle_reset, self._simple_process_stop, @@ -2107,7 +2112,7 @@ def line_batcher(ff_scan, t_shift=0., n=10): choices=['end', 'mid', 'az_endpoint1', 'az_endpoint2', 'mid_inc', 'mid_dec']) @ocs_agent.param('az_drift', type=float, default=None) - @ocs_agent.param('scan_type', default=1, choices=[1, 2, 3, 'sin_el_nod']) + @ocs_agent.param('scan_type', default=1, choices=[1, 2, 3]) @ocs_agent.param('az_vel_ref', type=float, default=None) @ocs_agent.param('turnaround_method', default=None, choices=[None, 'standard', 'standard_gen', @@ -2181,7 +2186,6 @@ def generate_scan(self, session, params): Type 1 is a constant elevation scan. Type 2 includes a variation in az speed that scales as sin(az). Type 3 is a Type 2 with an sinusoidal el nod. - 'sin_el_nod' is a sinusoidal el nod with no az movement. az_vel_ref (float or None): azimuth to center the velocity profile at. If None then the average of the endpoints is used. turnaround_method (str): The method used for generating turnaround. @@ -2245,10 +2249,6 @@ def generate_scan(self, session, params): if el_mode is None: el_mode = self.scan_params['el_mode'] # ... which may also be None. - # Check our az endpoints for sin_el_nods. They should be the same! - if params['scan_type'] == 'sin_el_nod' and az_endpoint1 != az_endpoint2: - raise ValueError("Cannot run type 4 scans with different az endpoints!") - # Check if the turnaround method is usable for the called scan type. # This should never happen with the above turnaround_method setting. if turnaround_method == "standard" and params['scan_type'] != 1: @@ -2300,8 +2300,6 @@ def generate_scan(self, session, params): az_cent = az_vel_ref - 90 az_edge = np.max(np.abs((az_endpoint1 - az_cent, az_endpoint2 - az_cent))) az_edge_speed = az_speed / np.sin(az_edge) - if params['scan_type'] == 'sin_el_nod': - az_edge_speed = 0 plan = sh.plan_scan(az_endpoint1, az_endpoint2, el=el_endpoint1, v_az=az_edge_speed, a_az=az_accel, @@ -2415,18 +2413,8 @@ def generate_scan(self, session, params): az_vel_ref=az_vel_ref, az_first_pos=plan['init_az'], **scan_params) - - elif params['scan_type'] == 'sin_el_nod': - free_form = True - track_axes = ['el'] - g = sh.generate_sin_el_nod(az=az_endpoint1, - el_endpoint1=el_endpoint1, - el_endpoint2=el_endpoint2, - el_freq=el_freq, - **scan_params) - else: - raise ValueError("Scan type must be 1, 2, 3, or 'sin_el_nod'") + raise ValueError("Scan type must be 1, 2, or 3") scan_params_bundle = {'session_id': session.session_id, 'schema': 1, @@ -2454,7 +2442,173 @@ def generate_scan(self, session, params): ret_val = (yield self._run_track( session=session, point_gen=g, step_time=step_time, stop_accel=az_accel, track_axes=track_axes, point_batch_count=point_batch_count, - free_form=free_form, unabort_failure=(params['scan_type'] in [2, 3, 'sin_el_nod']))) + free_form=free_form, unabort_failure=(params['scan_type'] in [2, 3]))) + + self.agent.publish_to_feed('scan_params', + {'timestamp': time.time(), + 'block_name': 'exit', + 'data': {'session_id': session.session_id, + 'event': 2}}) + return ret_val + + @ocs_agent.param('az', type=float) + @ocs_agent.param('el_endpoint1', type=float, default=None) + @ocs_agent.param('el_endpoint2', type=float, default=None) + @ocs_agent.param('el_freq', type=float, default=None) + @ocs_agent.param('num_nods', type=int, default=None) + @ocs_agent.param('start_time', type=float, default=None) + @inlineCallbacks + def generate_sin_el_nod(self, session, params): + """generate_sin_el_nod(az, \ + el_endpoint1=None, el_endpoint2=None, \ + el_speed=None, el_freq=None, \ + num_nods=None, start_time=None, \ + scan_upload_length=None) + + **Process** - Scan generator, currently only works for + constant-velocity az scans with fixed elevation. + + Parameters: + az (float): the azimuth where the nod will take place. + el_endpoint1 (float): first endpoint of elevation motion. + el_endpoint2 (float): second endpoint of the elevation motion. + el_freq (float): frequency of the elevation nods. + num_nods (int or None): if not None, limits the nods to + the specified number of sinusoidal nods. The + process will exit without error once that has + completed. + start_time (float or None): a unix timestamp giving the + time at which the scan should begin. The default is + None, which means the scan will start immediately (but + taking into account the value of wait_to_start). + wait_to_start (float): number of seconds to wait before + starting a scan, in the case that start_time is None. + The default is to compute a minimum time based on the + scan parameters and the ACU ramp-up algorithm; this is + typically 5-10 seconds. + + Notes: + Note that all parameters are optional except for + az, el_endpoint1 and el_endpoint2. If only those two parameters + are passed, the Process will scan between those endpoints, + with the elevation axis held in Stop, indefinitely (until + Process .stop method is called).. + + """ + init_time = time.time() # for params feed. + + if self._get_sun_policy('motion_blocked'): + return False, "Motion blocked; Sun avoidance in progress." + + self.log.info('User scan params: {params}', params=params) + + az = params['az'] + el_endpoint1 = params['el_endpoint1'] + el_endpoint2 = params['el_endpoint2'] + + # Params with defaults configured ... + el_freq = params['el_freq'] + if el_freq is None: + el_freq = self.scan_params['el_freq'] + + # Do we need to limit the elevation accel in some way...? + + # If el is not specified, drop in the current elevation. + if el_endpoint1 is None or el_endpoint2 is None: + raise ValueError("El endpoints must be specified for el nod!") + + if el_endpoint1 == el_endpoint2: + raise ValueError("El endpoints must not be the same for el nod!") + + # Could probably use a condition for if the el endpoints are too close? + + # If requested el is just outside acceptable range, tweak it in. + _f, _ = self._get_limit_func('elevation') + el_endpoint1, _untweaked_el = _f(el_endpoint1), el_endpoint1 + if abs(el_endpoint1 - _untweaked_el) > 0.1: + return False, "Current elevation (%.4f) is well outside limits." % _untweaked_el + init_el = el_endpoint1 + + scan_params = {k: params.get(k) for k in [ + 'num_nods', 'start_time'] + if params.get(k) is not None} + + self.log.info('The scan_params: {scan_params}', scan_params=scan_params) + + # Before any motion, check for sun safety. WE GOTTA MAKE THIS FOR NODS UGH + ok, msg = self._check_nod_sunsafe(az, el_endpoint1, el_endpoint2) + if ok: + self.log.info('Sun safety check: {msg}', msg=msg) + else: + self.log.error('Sun safety check fails: {msg}', msg=msg) + return False, 'Scan is not Sun Safe.' + + # Clear faults. + self.log.info('Clearing faults to prepare for motion.') + yield self.acu_control.clear_faults() + yield dsleep(1) + + # Verify we're good to move + ok, msg = yield self._check_ready_motion(session) + if not ok: + return False, msg + + # Seek to starting position. Note "legs" will always include + # at least 2 points; first point being current (az, el). + self.log.info(f'Moving to start position, az={az}, el={init_el}') + legs, msg = yield self._get_sunsafe_moves(az, init_el) + if msg is not None: + self.log.error(msg) + return False, msg + hwp_safe, msg = yield self._check_hwpsafe_legs(legs) + if not hwp_safe: + msg = f'Move to start position not permitted: {msg}' + self.log.info('{msg}', msg=msg) + return False, msg + + # Also validate the scan generally -- need to be movable in el. + hwp_safe, msg = yield self._check_hwpsafe(el_endpoint1, el_endpoint2, axes=['el']) + if not hwp_safe: + msg = f'El-nod not permitted: {msg}' + self.log.info(msg) + return False, msg + + for leg_az, leg_el in legs[1:]: + ok, msg = yield self._go_to_axes(session, az=leg_az, el=leg_el) + if not ok: + return False, f'Start position seek failed with message: {msg}' + + # Prepare the point generator. + free_form = True + track_axes = ['el'] + g = sh.generate_sin_el_nod(az=az, + el_endpoint1=el_endpoint1, + el_endpoint2=el_endpoint2, + el_freq=el_freq, + **scan_params) + + scan_params_bundle = {'session_id': session.session_id, + 'schema': 1, + 'event': 1, + 'init_time': init_time, + } + scan_params_bundle.update({ + 'az1': az, + 'el1': el_endpoint1, + 'el2': el_endpoint2, + 'el_freq': el_freq, + 'type': 'sin_el_nod', + 'track_axes': ','.join(track_axes), + }) + + self.agent.publish_to_feed('scan_params', + {'timestamp': time.time(), + 'block_name': 'info', + 'data': scan_params_bundle}) + + ret_val = (yield self._run_track( + session=session, point_gen=g, step_time=16 * el_freq, + track_axes=track_axes, free_form=free_form, unabort_failure=True)) self.agent.publish_to_feed('scan_params', {'timestamp': time.time(), @@ -3239,6 +3393,30 @@ def _check_scan_sunsafe(self, az1, az2, el, v_az, a_az): return safe, msg + def _check_nod_sunsafe(self, az, el1, el2): + """This will return True if active avoidance is disabled. If active + avoidance is enabled, then it will only return true if the + planned El-nod seems to currently be sun-safe. + + """ + if not self._get_sun_policy('sunsafe_moves'): + return True, 'Sun-safety checking is not enabled.' + + if not self._get_sun_policy('map_valid'): + return False, 'Sun Safety Map not computed or stale; run the monitor_sun process.' + + n = max(2, int(np.ceil((el2 - el1) / 1.))) + els = np.linspace(el1, el2, n) + + info = self.sun.check_trajectory(els * 0 + az, els) + safe = info['sun_time'] >= self.sun_params['policy']['min_sun_time'] + if safe: + msg = 'El-nod is safe for %.1f hours' % (info['sun_time'] / 3600) + else: + msg = 'El-nod will be unsafe in %.1f hours' % (info['sun_time'] / 3600) + + return safe, msg + def _get_sunsafe_moves(self, target_az, target_el): """Given a target position, find a Sun-safe way to get there. This will either be a direct move, or else an ordered slew in az diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index a53512a5f..e85d2325d 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -1005,7 +1005,6 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, num_nods=None, start_time=None, wait_to_start=10., - step_time=1., batch_size=500): """Python generator to produce times, azimuth and elevation positions, azimuth and elevation velocities, azimuth and elevation flags for @@ -1027,9 +1026,6 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, wait_to_start. wait_to_start (float): number of seconds to wait between start_time and when the scan actually starts. Default is 10 seconds. - step_time (float): time between points on the constant-velocity - parts of the motion. Default value is 1.0 seconds. Minimum value is - 0.05 seconds. batch_size (int): number of values to produce in each iteration. Default is 500. Batch size is reset to the length of one leg of the motion if num_batches is not None. @@ -1039,6 +1035,9 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, StopIteration once exit condition, if defined, is met. """ + POINTS_PER_NOD = 16 + MIN_STEP_TIME = 0.05 # in seconds. + # Get el throw el_throw = abs(el_endpoint2 - el_endpoint1) / 2 el_cent = (el_endpoint1 + el_endpoint2) / 2. @@ -1050,15 +1049,14 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, t = 0 el = el_endpoint1 - if step_time < 0.05: - raise ValueError('Time step size too small, must be at least ' - '0.05 seconds') - if num_batches is None: - stop_iter = float('inf') - else: - stop_iter = num_batches - batch_size = int(np.ceil(1 / (el_freq * step_time))) + # We want to divide each nod into at least 16 equadistant points. + # This gives a max frequency of el_freq < 1/(0.05*16) -> el_freq < 1.25. + if el_freq >= 1 / (POINTS_PER_NOD * MIN_STEP_TIME): + raise ValueError("El Freq is too high! El Freq must be < 1.25Hz to keep " + "step_time > 0.05!") + + step_time = POINTS_PER_NOD * el_freq # Divide nod into 16 points def check_completed_nods(): return num_nods is None or t * el_freq > num_nods @@ -1067,11 +1065,9 @@ def get_el(_t): return (el_cent - el_throw * np.cos(_t * el_freq * 2 * np.pi), el_throw * el_freq * 2 * np.pi * np.sin(_t * el_freq * 2 * np.pi)) - i = 0 - while i < stop_iter and check_completed_nods(): - i += 1 + while check_completed_nods(): point_block = [] - for j in range(batch_size): + for j in range(POINTS_PER_NOD): el, el_vel = get_el(t + t0) point_block.append(TrackPoint( timestamp=t + t0, From 865a232af0b7c1a3cfbccc27b5d3f95b9e740a41 Mon Sep 17 00:00:00 2001 From: mjrand Date: Wed, 1 Jul 2026 00:59:05 +0000 Subject: [PATCH 04/13] Updated step_time, generate loop, and various agent bits --- socs/agents/acu/agent.py | 69 ++++++++++++++++---------------- socs/agents/acu/drivers.py | 80 +++++++++++++++++++++++--------------- 2 files changed, 85 insertions(+), 64 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 1ad69fb95..37dae95a8 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -362,8 +362,8 @@ def __init__(self, agent, acu_config='guess', self._simple_process_stop, blocking=False, startup=False) - agent.register_process('generate_sin_el_nod', - self.generate_sin_el_nod, + agent.register_process('generate_el_nod', + self.generate_el_nod, self._simple_process_stop, blocking=False, startup=False) @@ -2451,27 +2451,24 @@ def generate_scan(self, session, params): 'event': 2}}) return ret_val - @ocs_agent.param('az', type=float) - @ocs_agent.param('el_endpoint1', type=float, default=None) - @ocs_agent.param('el_endpoint2', type=float, default=None) + @ocs_agent.param('el_depth', type=float, default=None) @ocs_agent.param('el_freq', type=float, default=None) @ocs_agent.param('num_nods', type=int, default=None) @ocs_agent.param('start_time', type=float, default=None) + @ocs_agent.param('step_time', type=float, default=0.1) @inlineCallbacks - def generate_sin_el_nod(self, session, params): - """generate_sin_el_nod(az, \ - el_endpoint1=None, el_endpoint2=None, \ - el_speed=None, el_freq=None, \ - num_nods=None, start_time=None, \ - scan_upload_length=None) + def generate_el_nod(self, session, params): + """generate_el_nod(el_depth=None, el_speed=None, el_freq=None, \ + num_nods=None, start_time=None, \ + scan_upload_length=None) **Process** - Scan generator, currently only works for constant-velocity az scans with fixed elevation. Parameters: - az (float): the azimuth where the nod will take place. - el_endpoint1 (float): first endpoint of elevation motion. - el_endpoint2 (float): second endpoint of the elevation motion. + el_depth (float): The number of degrees from the current + el to nod to. Can be negative. + Positive el_depth nods updwards, downwards for negative el_depth. el_freq (float): frequency of the elevation nods. num_nods (int or None): if not None, limits the nods to the specified number of sinusoidal nods. The @@ -2486,12 +2483,16 @@ def generate_sin_el_nod(self, session, params): The default is to compute a minimum time based on the scan parameters and the ACU ramp-up algorithm; this is typically 5-10 seconds. + step_time (float): time, in seconds, between points on the + constant-velocity parts of the motion. The default is + None, which will cause an appropriate value to be + chosen automatically (typically 0.1 to 1.0). Notes: Note that all parameters are optional except for - az, el_endpoint1 and el_endpoint2. If only those two parameters - are passed, the Process will scan between those endpoints, - with the elevation axis held in Stop, indefinitely (until + el_depth and el_freq. If only those two parameters + are passed, the Process will nod with that depth and frequency, + with the azimuth axis held in Stop, indefinitely (until Process .stop method is called).. """ @@ -2502,23 +2503,21 @@ def generate_sin_el_nod(self, session, params): self.log.info('User scan params: {params}', params=params) - az = params['az'] - el_endpoint1 = params['el_endpoint1'] - el_endpoint2 = params['el_endpoint2'] + az = self.data['status']['summary']['Azimuth_current_position'] + el = self.data['status']['summary']['Elevation_current_position'] + + el_depth = params['el_depth'] + + el_endpoint1 = el + el_endpoint2 = el + el_depth # Params with defaults configured ... el_freq = params['el_freq'] if el_freq is None: el_freq = self.scan_params['el_freq'] - # Do we need to limit the elevation accel in some way...? - - # If el is not specified, drop in the current elevation. - if el_endpoint1 is None or el_endpoint2 is None: - raise ValueError("El endpoints must be specified for el nod!") - - if el_endpoint1 == el_endpoint2: - raise ValueError("El endpoints must not be the same for el nod!") + if el_depth == 0: + raise ValueError("El depth must not be equal to 0 for el nod!") # Could probably use a condition for if the el endpoints are too close? @@ -2530,7 +2529,7 @@ def generate_sin_el_nod(self, session, params): init_el = el_endpoint1 scan_params = {k: params.get(k) for k in [ - 'num_nods', 'start_time'] + 'num_nods', 'start_time', 'step_time'] if params.get(k) is not None} self.log.info('The scan_params: {scan_params}', scan_params=scan_params) @@ -2581,8 +2580,7 @@ def generate_sin_el_nod(self, session, params): # Prepare the point generator. free_form = True track_axes = ['el'] - g = sh.generate_sin_el_nod(az=az, - el_endpoint1=el_endpoint1, + g = sh.generate_sin_el_nod(az=az, el_endpoint1=el_endpoint1, el_endpoint2=el_endpoint2, el_freq=el_freq, **scan_params) @@ -2592,12 +2590,17 @@ def generate_sin_el_nod(self, session, params): 'event': 1, 'init_time': init_time, } + scan_params_bundle.update({ 'az1': az, + 'az2': az, + 'az_vel': 0, + 'az_accel': 0, 'el1': el_endpoint1, 'el2': el_endpoint2, 'el_freq': el_freq, - 'type': 'sin_el_nod', + 'type': 4, + 'turnaround_type': 0, 'track_axes': ','.join(track_axes), }) @@ -3405,7 +3408,7 @@ def _check_nod_sunsafe(self, az, el1, el2): if not self._get_sun_policy('map_valid'): return False, 'Sun Safety Map not computed or stale; run the monitor_sun process.' - n = max(2, int(np.ceil((el2 - el1) / 1.))) + n = max(2, int(np.ceil((abs(el2 - el1)) / 1.))) els = np.linspace(el1, el2, n) info = self.sun.check_trajectory(els * 0 + az, els) diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index e85d2325d..98ed0db49 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -1005,13 +1005,13 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, num_nods=None, start_time=None, wait_to_start=10., + step_time=0.05, batch_size=500): """Python generator to produce times, azimuth and elevation positions, azimuth and elevation velocities, azimuth and elevation flags for arbitrarily long sin el nods. Parameters: - az (float): azimuth position for the el nods. el_endpoint1 (float): elevation endpoint for the scan start el_endpoint2 (float): second elevation endpoint of the scan. For constant az scans, this must be equal to el_endpoint1. @@ -1026,6 +1026,9 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, wait_to_start. wait_to_start (float): number of seconds to wait between start_time and when the scan actually starts. Default is 10 seconds. + step_time (float): time between points on the constant-velocity + parts of the motion. Default value is 1.0 seconds. Minimum value is + 0.05 seconds. batch_size (int): number of values to produce in each iteration. Default is 500. Batch size is reset to the length of one leg of the motion if num_batches is not None. @@ -1035,11 +1038,11 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, StopIteration once exit condition, if defined, is met. """ - POINTS_PER_NOD = 16 - MIN_STEP_TIME = 0.05 # in seconds. - # Get el throw - el_throw = abs(el_endpoint2 - el_endpoint1) / 2 + MIN_STEP_TIME = 0.05 # in seconds. + MIN_POINTS_PER_NOD = 16 + # Get el throw. + el_throw = (el_endpoint2 - el_endpoint1) / 2 el_cent = (el_endpoint1 + el_endpoint2) / 2. if start_time is None: @@ -1050,43 +1053,58 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, t = 0 el = el_endpoint1 - # We want to divide each nod into at least 16 equadistant points. - # This gives a max frequency of el_freq < 1/(0.05*16) -> el_freq < 1.25. - if el_freq >= 1 / (POINTS_PER_NOD * MIN_STEP_TIME): - raise ValueError("El Freq is too high! El Freq must be < 1.25Hz to keep " - "step_time > 0.05!") + # Ensure the step_time isn't too low. + if step_time < MIN_STEP_TIME: + step_time = MIN_STEP_TIME + + nod_period = 1. / el_freq + points_per_nod = round(nod_period / step_time) + + # Tweak points per nod to the closest multiple of 4. + # For clean sine/cos waves. + points_per_nod = round(points_per_nod / 4) * 4 - step_time = POINTS_PER_NOD * el_freq # Divide nod into 16 points + # Ensure we have the minumum number of points + if points_per_nod < MIN_POINTS_PER_NOD: + points_per_nod = MIN_POINTS_PER_NOD + print(points_per_nod) + + step_time = nod_period / points_per_nod # Divide nod into points_per_nod points. def check_completed_nods(): - return num_nods is None or t * el_freq > num_nods + return num_nods is None or t * el_freq < num_nods def get_el(_t): return (el_cent - el_throw * np.cos(_t * el_freq * 2 * np.pi), el_throw * el_freq * 2 * np.pi * np.sin(_t * el_freq * 2 * np.pi)) - while check_completed_nods(): - point_block = [] - for j in range(POINTS_PER_NOD): - el, el_vel = get_el(t + t0) - point_block.append(TrackPoint( - timestamp=t + t0, - az=az, el=el, az_vel=0, el_vel=el_vel, - az_flag=0, el_flag=1, - group_flag=0)) - - t += step_time - - if not check_completed_nods(): - # Kill the velocity on the last point and exit -- this - # was recommended at LAT FAT for smoothly stopping the - # motion at end of program. - point_block[-1].az_vel = 0 - point_block[-1].el_vel = 0 - break + # Set up templates for the ts, els, and el_vels for each nod. + # Each nod is identical in el and el_vel, + # we just need to update the times as we complete nods. + template_nod_ts = step_time * np.arange(points_per_nod) + template_nod_els, template_nod_el_vels = get_el(template_nod_ts) + # Upload full nods until we've completed num_nods. + while check_completed_nods(): + # Create a point_block for the entire nod using our templates. + point_block = [TrackPoint(timestamp=nod_t + t + t0, + az=az, el=el, az_vel=0, el_vel=el_vel, + az_flag=0, el_flag=1, group_flag=1) + for nod_t, el, el_vel in + zip(template_nod_ts, template_nod_els, template_nod_el_vels)] + + # Set the group flag of the last point to 0 so the entire nod gets uploaded at once. + point_block[-1].group_flag = 0 + + # Update the time. + t += 1. / el_freq yield point_block + # Yield one final point with 0 velocities. + yield [TrackPoint(timestamp=t + t0, + az=az, el=el, az_vel=0, el_vel=0, + az_flag=0, el_flag=1, group_flag=0)] + def plan_scan(az_end1, az_end2, el, v_az=1, a_az=1, az_start=None, scan_type=1): From 49c31ae309a5e8bdd705575b9414ab8f70aaecab Mon Sep 17 00:00:00 2001 From: mjrand Date: Wed, 1 Jul 2026 01:26:32 +0000 Subject: [PATCH 05/13] Removed print from drivers generate function --- socs/agents/acu/drivers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index 98ed0db49..93caf5b19 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -1067,7 +1067,6 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, # Ensure we have the minumum number of points if points_per_nod < MIN_POINTS_PER_NOD: points_per_nod = MIN_POINTS_PER_NOD - print(points_per_nod) step_time = nod_period / points_per_nod # Divide nod into points_per_nod points. From 710d5db6a03ad6775038e8ff7023394ae93a2a49 Mon Sep 17 00:00:00 2001 From: mjrand Date: Fri, 3 Jul 2026 22:18:56 +0000 Subject: [PATCH 06/13] Updated _run_track check for ProgramTrack mode to check track_axes --- socs/agents/acu/agent.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 37dae95a8..df04b27c2 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2759,25 +2759,37 @@ def _run_track(self, session, point_gen, step_time, stop_accel=0.5, track_axes=[ faults[k] = True if mode != 'abort': - # Reasons we might decide to abort ... - if current_modes['Az'] == 'ProgramTrack': - got_progtrack = True - else: - if got_progtrack: - self.log.warn('Unexpected exit from ProgramTrack mode!') - if mode == 'stop': - prog_track_err = True - mode = 'abort' - elif now - start_time > MAX_PROGTRACK_SET_TIME: - self.log.warn('Failed to set ProgramTrack mode in a timely fashion.') - mode = 'abort' + # Reasons we might decide to abort or stop ... + + # First we'll check that each axis we expect to be in ProgramTrack mode, + # is indeed still in ProgramTrack mode. + for ax in track_axes: + # Convert between track axes names and status field names. + track_axes_names = {'az': 'Az', 'el': 'El'} # There's probably a better way to convert these. + if current_modes[track_axes_names[ax]] == 'ProgramTrack': + got_progtrack = True + else: + if got_progtrack: + self.log.warn('Unexpected exit from ProgramTrack mode!') + if mode == 'stop': + prog_track_err = True + mode = 'abort' + elif now - start_time > MAX_PROGTRACK_SET_TIME: + self.log.warn('Failed to set ProgramTrack mode in a timely fashion.') + mode = 'abort' + + # If we've attempted to upload points does the ACU actually have them? if not got_points_in and (first_upload_time is not None) \ and (now - first_upload_time > 10): self.log.warn('ACU seems to be dumping our track. Vel too high?') mode = 'abort' + + # Have we left Remote mode? if current_modes['Remote'] == 0: self.log.warn('ACU no longer in remote mode!') mode = 'abort' + + # Have we requested a stop? if session.status == 'stopping' and mode not in ['stop', 'abort']: mode = 'stop' stop_message = 'User-requested stop.' From 71f0490c037c6e28223faa8e539a12dac23852e4 Mon Sep 17 00:00:00 2001 From: mjrand Date: Tue, 7 Jul 2026 01:33:56 +0000 Subject: [PATCH 07/13] Fixed get_progrtrack checks for multiple axes. Removed initial el_nod moves --- socs/agents/acu/agent.py | 61 ++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index df04b27c2..09b1e8d2a 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2451,7 +2451,7 @@ def generate_scan(self, session, params): 'event': 2}}) return ret_val - @ocs_agent.param('el_depth', type=float, default=None) + @ocs_agent.param('el_depth', type=float) @ocs_agent.param('el_freq', type=float, default=None) @ocs_agent.param('num_nods', type=int, default=None) @ocs_agent.param('start_time', type=float, default=None) @@ -2462,27 +2462,27 @@ def generate_el_nod(self, session, params): num_nods=None, start_time=None, \ scan_upload_length=None) - **Process** - Scan generator, currently only works for - constant-velocity az scans with fixed elevation. + **Process** - Scan generator. Generates sinusoidal elevation nods + at the current azimuth and elevation of the platform. Nods are generated + with frequency equal to el_freq and amplitude equal to el_depth. + For a given el_depth, the platform will nod from the current elevation to + current elevation + el_depth. + + This function publishes scan_params to the feed with scan_type = 4 . Parameters: el_depth (float): The number of degrees from the current - el to nod to. Can be negative. - Positive el_depth nods updwards, downwards for negative el_depth. - el_freq (float): frequency of the elevation nods. + el to nod to. Can be negative. + Positive el_depth nods updwards, downwards for negative el_depth. + el_freq (float or None): frequency of the elevation nods. + If None, scan_params['el_freq'] is used. num_nods (int or None): if not None, limits the nods to the specified number of sinusoidal nods. The process will exit without error once that has completed. start_time (float or None): a unix timestamp giving the time at which the scan should begin. The default is - None, which means the scan will start immediately (but - taking into account the value of wait_to_start). - wait_to_start (float): number of seconds to wait before - starting a scan, in the case that start_time is None. - The default is to compute a minimum time based on the - scan parameters and the ACU ramp-up algorithm; this is - typically 5-10 seconds. + None, which means the scan will start immediately. step_time (float): time, in seconds, between points on the constant-velocity parts of the motion. The default is None, which will cause an appropriate value to be @@ -2526,15 +2526,14 @@ def generate_el_nod(self, session, params): el_endpoint1, _untweaked_el = _f(el_endpoint1), el_endpoint1 if abs(el_endpoint1 - _untweaked_el) > 0.1: return False, "Current elevation (%.4f) is well outside limits." % _untweaked_el - init_el = el_endpoint1 scan_params = {k: params.get(k) for k in [ 'num_nods', 'start_time', 'step_time'] if params.get(k) is not None} + step_time = scan_params['step_time'] self.log.info('The scan_params: {scan_params}', scan_params=scan_params) - # Before any motion, check for sun safety. WE GOTTA MAKE THIS FOR NODS UGH ok, msg = self._check_nod_sunsafe(az, el_endpoint1, el_endpoint2) if ok: self.log.info('Sun safety check: {msg}', msg=msg) @@ -2552,19 +2551,6 @@ def generate_el_nod(self, session, params): if not ok: return False, msg - # Seek to starting position. Note "legs" will always include - # at least 2 points; first point being current (az, el). - self.log.info(f'Moving to start position, az={az}, el={init_el}') - legs, msg = yield self._get_sunsafe_moves(az, init_el) - if msg is not None: - self.log.error(msg) - return False, msg - hwp_safe, msg = yield self._check_hwpsafe_legs(legs) - if not hwp_safe: - msg = f'Move to start position not permitted: {msg}' - self.log.info('{msg}', msg=msg) - return False, msg - # Also validate the scan generally -- need to be movable in el. hwp_safe, msg = yield self._check_hwpsafe(el_endpoint1, el_endpoint2, axes=['el']) if not hwp_safe: @@ -2572,11 +2558,6 @@ def generate_el_nod(self, session, params): self.log.info(msg) return False, msg - for leg_az, leg_el in legs[1:]: - ok, msg = yield self._go_to_axes(session, az=leg_az, el=leg_el) - if not ok: - return False, f'Start position seek failed with message: {msg}' - # Prepare the point generator. free_form = True track_axes = ['el'] @@ -2610,7 +2591,7 @@ def generate_el_nod(self, session, params): 'data': scan_params_bundle}) ret_val = (yield self._run_track( - session=session, point_gen=g, step_time=16 * el_freq, + session=session, point_gen=g, step_time=step_time, track_axes=track_axes, free_form=free_form, unabort_failure=True)) self.agent.publish_to_feed('scan_params', @@ -2725,7 +2706,7 @@ def _run_track(self, session, point_gen, step_time, stop_accel=0.5, track_axes=[ last_mode = None last_upload_az = None start_time = time.time() - got_progtrack = False + got_progtrack = {ax: False for ax in track_axes} faults = {} got_points_in = False first_upload_time = None @@ -2747,7 +2728,7 @@ def _run_track(self, session, point_gen, step_time, stop_accel=0.5, track_axes=[ # points but ACU is quietly dumping them because the # vel is too high. got_points_in = got_points_in \ - or (got_progtrack and free_positions < FULL_STACK) + or (any(got_progtrack.values()) and free_positions < FULL_STACK) if last_mode != mode: self.log.info(f'scan mode={mode}, line_buffer={len(point_prov)}, track_free={free_positions}') @@ -2767,15 +2748,15 @@ def _run_track(self, session, point_gen, step_time, stop_accel=0.5, track_axes=[ # Convert between track axes names and status field names. track_axes_names = {'az': 'Az', 'el': 'El'} # There's probably a better way to convert these. if current_modes[track_axes_names[ax]] == 'ProgramTrack': - got_progtrack = True + got_progtrack[ax] = True else: - if got_progtrack: - self.log.warn('Unexpected exit from ProgramTrack mode!') + if got_progtrack[ax]: + self.log.warn(f'Unexpected exit from ProgramTrack mode on {ax} axis!') if mode == 'stop': prog_track_err = True mode = 'abort' elif now - start_time > MAX_PROGTRACK_SET_TIME: - self.log.warn('Failed to set ProgramTrack mode in a timely fashion.') + self.log.warn(f'Failed to set ProgramTrack mode in a timely fashion on {ax} axis.') mode = 'abort' # If we've attempted to upload points does the ACU actually have them? From 12bbba37efde00678abc13dceb6791dd46d56a64 Mon Sep 17 00:00:00 2001 From: mjrand Date: Tue, 18 Aug 2026 00:01:12 +0000 Subject: [PATCH 08/13] Fixed nod counter and misc documentation --- socs/agents/acu/agent.py | 23 +++++++++-------------- socs/agents/acu/drivers.py | 15 +++++---------- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 09b1e8d2a..4d77be15d 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2485,15 +2485,14 @@ def generate_el_nod(self, session, params): None, which means the scan will start immediately. step_time (float): time, in seconds, between points on the constant-velocity parts of the motion. The default is - None, which will cause an appropriate value to be - chosen automatically (typically 0.1 to 1.0). + 0.1 seconds. Notes: Note that all parameters are optional except for - el_depth and el_freq. If only those two parameters - are passed, the Process will nod with that depth and frequency, - with the azimuth axis held in Stop, indefinitely (until - Process .stop method is called).. + el_depth. If only those el_depth is passed, the Process will nod with that + depth and theel_freq specified in the scan_params (if it is specified) + indefinitely (until Process .stop method is called). + The azimuth axis held in Stop while the elevation nods are executed. """ init_time = time.time() # for params feed. @@ -2516,10 +2515,8 @@ def generate_el_nod(self, session, params): if el_freq is None: el_freq = self.scan_params['el_freq'] - if el_depth == 0: - raise ValueError("El depth must not be equal to 0 for el nod!") - - # Could probably use a condition for if the el endpoints are too close? + if abs(el_depth) < 0.1: + raise ValueError("El depth amplitude must be greater than 0.1 for el nod!") # If requested el is just outside acceptable range, tweak it in. _f, _ = self._get_limit_func('elevation') @@ -2530,7 +2527,7 @@ def generate_el_nod(self, session, params): scan_params = {k: params.get(k) for k in [ 'num_nods', 'start_time', 'step_time'] if params.get(k) is not None} - step_time = scan_params['step_time'] + step_time = 0.1 if 'step_time' not in scan_params.keys() else scan_params['step_time'] self.log.info('The scan_params: {scan_params}', scan_params=scan_params) @@ -2745,9 +2742,7 @@ def _run_track(self, session, point_gen, step_time, stop_accel=0.5, track_axes=[ # First we'll check that each axis we expect to be in ProgramTrack mode, # is indeed still in ProgramTrack mode. for ax in track_axes: - # Convert between track axes names and status field names. - track_axes_names = {'az': 'Az', 'el': 'El'} # There's probably a better way to convert these. - if current_modes[track_axes_names[ax]] == 'ProgramTrack': + if current_modes[ax.capitalize()] == 'ProgramTrack': got_progtrack[ax] = True else: if got_progtrack[ax]: diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index 93caf5b19..1a0c55a1d 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -1001,12 +1001,10 @@ def generate_type2_scan(az_endpoint1, az_endpoint2, az_speed, def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, el_freq=.15, - num_batches=None, num_nods=None, start_time=None, wait_to_start=10., - step_time=0.05, - batch_size=500): + step_time=0.05): """Python generator to produce times, azimuth and elevation positions, azimuth and elevation velocities, azimuth and elevation flags for arbitrarily long sin el nods. @@ -1016,9 +1014,6 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, el_endpoint2 (float): second elevation endpoint of the scan. For constant az scans, this must be equal to el_endpoint1. el_freq(float): frequency of the elevation nods in Hz. - num_batches (int or None): sets the number of batches for the - generator to create. Default value is None (interpreted as infinite - batches). num_nods (int or None): if not None, limits the points returned to the specified number of el nods (one nod is one full sine wave). start_time (float or None): a ctime at which to start the scan. @@ -1029,9 +1024,6 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, step_time (float): time between points on the constant-velocity parts of the motion. Default value is 1.0 seconds. Minimum value is 0.05 seconds. - batch_size (int): number of values to produce in each iteration. - Default is 500. Batch size is reset to the length of one leg of the - motion if num_batches is not None. Yields: points (list): a list of TrackPoint objects. Raises @@ -1071,7 +1063,7 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, step_time = nod_period / points_per_nod # Divide nod into points_per_nod points. def check_completed_nods(): - return num_nods is None or t * el_freq < num_nods + return num_nods is None or num_completed_nods < num_nods def get_el(_t): return (el_cent - el_throw * np.cos(_t * el_freq * 2 * np.pi), @@ -1084,6 +1076,7 @@ def get_el(_t): template_nod_els, template_nod_el_vels = get_el(template_nod_ts) # Upload full nods until we've completed num_nods. + num_completed_nods = 0 while check_completed_nods(): # Create a point_block for the entire nod using our templates. point_block = [TrackPoint(timestamp=nod_t + t + t0, @@ -1099,6 +1092,8 @@ def get_el(_t): t += 1. / el_freq yield point_block + num_completed_nods += 1 + # Yield one final point with 0 velocities. yield [TrackPoint(timestamp=t + t0, az=az, el=el, az_vel=0, el_vel=0, From 5aae3b4942f5797b7cac737baf4171e71246e43f Mon Sep 17 00:00:00 2001 From: mjrand Date: Wed, 14 Oct 2026 03:44:36 +0000 Subject: [PATCH 09/13] El freq check and small docstring changes --- socs/agents/acu/agent.py | 3 +++ socs/agents/acu/drivers.py | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 4d77be15d..9b77e4d3d 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2518,6 +2518,9 @@ def generate_el_nod(self, session, params): if abs(el_depth) < 0.1: raise ValueError("El depth amplitude must be greater than 0.1 for el nod!") + if abs(el_freq) < 0.001: + raise ValueError("El freq must be greater than 0.001 Hz for el nod!") + # If requested el is just outside acceptable range, tweak it in. _f, _ = self._get_limit_func('elevation') el_endpoint1, _untweaked_el = _f(el_endpoint1), el_endpoint1 diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index 1a0c55a1d..2d6c17707 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -1010,9 +1010,12 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, arbitrarily long sin el nods. Parameters: - el_endpoint1 (float): elevation endpoint for the scan start - el_endpoint2 (float): second elevation endpoint of the scan. For - constant az scans, this must be equal to el_endpoint1. + az (float): azimuth position to do the sin el nod. Azimuth position + stays constant throughout the sin el nod. + el_endpoint1 (float): elevation endpoint for the el nod start. + el_endpoint2 (float): second elevation endpoint of the scan. + Can be greater than el_endpoint1 for a positive el nod or + less than el_endpoint2 for a negative el nod. el_freq(float): frequency of the elevation nods in Hz. num_nods (int or None): if not None, limits the points returned to the specified number of el nods (one nod is one full sine wave). From f9be6485793d02ebfca51270591779692806ab6a Mon Sep 17 00:00:00 2001 From: mjrand Date: Wed, 14 Oct 2026 03:48:49 +0000 Subject: [PATCH 10/13] Removed abs from el freq check --- socs/agents/acu/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 9b77e4d3d..3f0673a5f 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2518,7 +2518,7 @@ def generate_el_nod(self, session, params): if abs(el_depth) < 0.1: raise ValueError("El depth amplitude must be greater than 0.1 for el nod!") - if abs(el_freq) < 0.001: + if el_freq < 0.001: raise ValueError("El freq must be greater than 0.001 Hz for el nod!") # If requested el is just outside acceptable range, tweak it in. From 6b2f83d98026d6e86bdd3d222ea7ca74d7cd4e0f Mon Sep 17 00:00:00 2001 From: Matthew Hasselfield Date: Wed, 14 Oct 2026 08:58:53 -0400 Subject: [PATCH 11/13] Update socs/agents/acu/agent.py --- socs/agents/acu/agent.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 3f0673a5f..d605ad1e3 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2458,9 +2458,8 @@ def generate_scan(self, session, params): @ocs_agent.param('step_time', type=float, default=0.1) @inlineCallbacks def generate_el_nod(self, session, params): - """generate_el_nod(el_depth=None, el_speed=None, el_freq=None, \ - num_nods=None, start_time=None, \ - scan_upload_length=None) + """generate_el_nod(el_depth, el_freq=None, num_nods=None, \ + start_time=None, step_time=None) **Process** - Scan generator. Generates sinusoidal elevation nods at the current azimuth and elevation of the platform. Nods are generated From 7e21c9ee3c1257efede260b64870678499019c47 Mon Sep 17 00:00:00 2001 From: Matthew Hasselfield Date: Wed, 14 Oct 2026 08:59:35 -0400 Subject: [PATCH 12/13] Fix typos Co-authored-by: Matthew Hasselfield --- socs/agents/acu/agent.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index d605ad1e3..00391396f 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2472,7 +2472,7 @@ def generate_el_nod(self, session, params): Parameters: el_depth (float): The number of degrees from the current el to nod to. Can be negative. - Positive el_depth nods updwards, downwards for negative el_depth. + Positive el_depth nods upwards, downwards for negative el_depth. el_freq (float or None): frequency of the elevation nods. If None, scan_params['el_freq'] is used. num_nods (int or None): if not None, limits the nods to @@ -2488,8 +2488,8 @@ def generate_el_nod(self, session, params): Notes: Note that all parameters are optional except for - el_depth. If only those el_depth is passed, the Process will nod with that - depth and theel_freq specified in the scan_params (if it is specified) + el_depth. If only the el_depth is passed, the Process will nod with that + depth and the el_freq specified in the scan_params (if it is specified) indefinitely (until Process .stop method is called). The azimuth axis held in Stop while the elevation nods are executed. From ae8f60cb3785997eb9d437629ef1ae1c9a727559 Mon Sep 17 00:00:00 2001 From: Matthew Hasselfield Date: Mon, 19 Oct 2026 11:53:05 -0400 Subject: [PATCH 13/13] Fix docstrings Co-authored-by: Matthew Hasselfield --- socs/agents/acu/agent.py | 2 +- socs/agents/acu/drivers.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/socs/agents/acu/agent.py b/socs/agents/acu/agent.py index 00391396f..d4edd521d 100644 --- a/socs/agents/acu/agent.py +++ b/socs/agents/acu/agent.py @@ -2459,7 +2459,7 @@ def generate_scan(self, session, params): @inlineCallbacks def generate_el_nod(self, session, params): """generate_el_nod(el_depth, el_freq=None, num_nods=None, \ - start_time=None, step_time=None) + start_time=None, step_time=0.1) **Process** - Scan generator. Generates sinusoidal elevation nods at the current azimuth and elevation of the platform. Nods are generated diff --git a/socs/agents/acu/drivers.py b/socs/agents/acu/drivers.py index 2d6c17707..30f3665e2 100644 --- a/socs/agents/acu/drivers.py +++ b/socs/agents/acu/drivers.py @@ -1016,7 +1016,7 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, el_endpoint2 (float): second elevation endpoint of the scan. Can be greater than el_endpoint1 for a positive el nod or less than el_endpoint2 for a negative el nod. - el_freq(float): frequency of the elevation nods in Hz. + el_freq(float): frequency of the elevation nods in Hz. Defaults to 0.15. num_nods (int or None): if not None, limits the points returned to the specified number of el nods (one nod is one full sine wave). start_time (float or None): a ctime at which to start the scan. @@ -1024,9 +1024,10 @@ def generate_sin_el_nod(az, el_endpoint1, el_endpoint2, wait_to_start. wait_to_start (float): number of seconds to wait between start_time and when the scan actually starts. Default is 10 seconds. - step_time (float): time between points on the constant-velocity - parts of the motion. Default value is 1.0 seconds. Minimum value is - 0.05 seconds. + step_time (float): suggested time between points on the constant-velocity + parts of the motion. Actual value will be computed to fit evenly into + a single nod period. Default value is 0.05 seconds, and that's also + the minimum accepted value. Yields: points (list): a list of TrackPoint objects. Raises